Skip to content
Draft
5 changes: 5 additions & 0 deletions apps/mobile/src/features/threads/PendingApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
<Text className="font-t3-bold text-lg text-foreground">
{props.approval.appName ?? props.approval.requestKind}
</Text>
{props.approval.remoteEnvironmentLabel ? (
<Text className="font-sans text-xs leading-normal text-foreground-muted">
Delegated task on {props.approval.remoteEnvironmentLabel}
</Text>
) : null}
{props.approval.detail ? (
<Text className="font-sans text-sm leading-normal text-foreground-secondary">
{props.approval.detail}
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
run to continue.
</Text>
) : null}
{props.pendingUserInput.remoteEnvironmentLabel ? (
<Text className="font-sans text-xs leading-normal text-foreground-muted">
Delegated task on {props.pendingUserInput.remoteEnvironmentLabel}
</Text>
) : null}
{props.pendingUserInput.questions.map((question) => {
const draft = props.drafts[question.id];
return (
Expand Down Expand Up @@ -271,6 +276,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
question={question}
questions={props.pendingUserInput.questions}
disabled={responseDisabled}
attachable={props.pendingUserInput.remoteTaskId === undefined}
value={draft?.customAnswer ?? ""}
onChangeText={(value) =>
props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/QuestionAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function QuestionAttachments(props: {
question: UserInputQuestion;
questions: ReadonlyArray<UserInputQuestion>;
disabled: boolean;
attachable: boolean;
value: string;
onChangeText: (value: string) => void;
onInputFocusChange?: ((focused: boolean) => void) | undefined;
Expand Down Expand Up @@ -86,6 +87,10 @@ export function QuestionAttachments(props: {
const paste = useNativePaste((uris) => {
const scope = pickerScope.current;
if (!selectedThread || props.disabled || !configs.get(selectedThread.environmentId)) return;
if (!props.attachable) {
Dialog.alert("Could not paste image", "This question cannot accept attachments.");
return;
}
const key = questionAttachmentDraftKey(
selectedThread.environmentId,
selectedThread.id,
Expand Down Expand Up @@ -117,7 +122,7 @@ export function QuestionAttachments(props: {
if (!selectedThread || props.question.allowCustomAnswer === false) return null;
const { environmentId, id: threadId } = selectedThread;
const capabilities = configs.get(environmentId)?.environment.capabilities;
const canAttach = capabilities !== undefined;
const canAttach = props.attachable && capabilities !== undefined;
const key = questionAttachmentDraftKey(
environmentId,
threadId,
Expand Down
19 changes: 11 additions & 8 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from "@t3tools/shared/codexArtifactTemplates";
import {
declinePendingApprovals,
redirectableApprovals,
sendApprovalFeedback,
type ApprovalResponder,
type ThreadUserInputQuestion,
Expand Down Expand Up @@ -1080,12 +1081,15 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
: [];
})();

const redirectedApprovals = redirectableApprovals(props.activePendingApprovals);
const currentPendingApprovals = () =>
props.selectedThreadDetailRef === null
? []
: (appAtomRegistry.get(
environmentThreadDetails.pendingRequestsAtom(props.selectedThreadDetailRef),
)?.approvals ?? []);
: redirectableApprovals(
appAtomRegistry.get(
environmentThreadDetails.pendingRequestsAtom(props.selectedThreadDetailRef),
)?.approvals ?? [],
);
const handleSendMessage = async (
followUp?: ActiveTurnComposerAction,
stillSendable?: () => boolean,
Expand All @@ -1097,7 +1101,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
);
// Runs only once the send path has validated the draft and is about to deliver it, so an
// empty submit, a queued-message edit, or `/feedback` leaves approvals alone.
const approvalsToDecline = props.activePendingApprovals;
const approvalsToDecline = redirectedApprovals;
const declineBeforeDispatch =
approvalsToDecline.length > 0 && props.activePendingUserInput === null
? async (text: string, hasAttachmentsOrContext: boolean) => {
Expand Down Expand Up @@ -1577,8 +1581,8 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
reportedModelSelection={reportedModelSelection}
editorRef={composerEditorRef}
placeholder={
props.activePendingApproval
? props.activePendingApproval.supportsFeedback
redirectedApprovals[0]
? redirectedApprovals[0].supportsFeedback
? "Send feedback to decline this request"
: "Send a message to decline and redirect the agent"
: "Ask anything"
Expand Down Expand Up @@ -1609,8 +1613,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
interactionLocked={props.draftMode?.interactionLocked}
draftKey={props.composerDraftKey ?? undefined}
followUpBehavior={
props.activePendingApprovals.length > 0 &&
props.activePendingUserInput === null
redirectedApprovals.length > 0 && props.activePendingUserInput === null
? "steer"
: props.followUpBehavior
}
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ export const hiddenWhileLinksOff = (): boolean => {
return scope?.linkedEnvironments !== false;
};

/** Another T3 Code environment's peer link calling on its own behalf, not an agent here. */
export const isLinkSession = (scope: McpInvocationScope | undefined): boolean =>
scope?.thread === undefined && scope?.client?.linked === true;

/** `McpSchema.EnabledWhen` for tools only a peer link's own environment calls. */
export const visibleToLinkSessions = (): boolean => {
const fiber = Fiber.getCurrent();
return isLinkSession(fiber && Context.getOrUndefined(fiber.context, McpInvocationContext));
};

export type McpCapabilityError<C extends McpCapability> = C extends "preview"
? PreviewAutomationUnavailableError
: McpCapabilityUnavailableError;
Expand Down
53 changes: 41 additions & 12 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
NodeId,
ORCHESTRATOR_MCP_RUNNING_TASK_NEXT,
TurnItemId,
type OrchestrationV2RemoteTaskRequest,
type OrchestrationV2Run,
type OrchestrationV2Subagent,
type OrchestrationV2ThreadProjection,
Expand Down Expand Up @@ -63,6 +64,7 @@ import {
type ServerProviderModel,
ThreadId,
isOrchestrationV2WorkActive,
unansweredRemoteTaskRequests,
} from "@t3tools/contracts";
import { getProviderOptionCurrentValue } from "@t3tools/shared/model";
import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
Expand Down Expand Up @@ -131,6 +133,10 @@ const TASK_WAKE_EVENTS = [
{ thread: "child", eventType: "provider-thread.updated" },
{ thread: "child", eventType: "runtime-request.updated" },
] as const;
const REMOTE_TASK_WAKE_EVENTS = [
{ thread: "parent", eventType: "subagent.updated" },
{ thread: "parent", eventType: "thread.metadata-updated" },
] as const;
const SECRET_REQUEST_POLL_INTERVAL_MS = 500;
const DEFAULT_THREAD_LIST_LIMIT = 50;
const DEFAULT_THREAD_READ_LIMIT = 50;
Expand Down Expand Up @@ -510,6 +516,23 @@ function pendingTaskApproval(
return approval;
}

/** The oldest approval a remote task's thread there waits on that the user has not answered here. */
function remoteTaskApproval(
task: OrchestrationV2Subagent,
unanswered: ReadonlyArray<OrchestrationV2RemoteTaskRequest>,
): OrchestratorMcpDelegateTaskResult["pendingApproval"] {
for (const request of unanswered.toSorted(
(left, right) =>
DateTime.toEpochMillis(left.createdAt) - DateTime.toEpochMillis(right.createdAt),
)) {
const runId = request.runId ?? task.remoteChild?.runId;
if (request.kind !== "user_input" && runId !== undefined) {
return { requestId: request.requestId, kind: request.kind, runId };
}
}
return null;
}

function directAppOwnedChildTasks(
parent: Pick<OrchestrationV2ThreadProjection, "thread" | "subagents">,
target: Pick<OrchestrationV2ThreadProjection, "thread" | "runs">,
Expand Down Expand Up @@ -1411,7 +1434,15 @@ const make = Effect.gen(function* () {
);
}
if (task.childThreadId === null) {
return yield* readRemoteTask(scope, task, waitTimedOut, acknowledgeTerminal);
return yield* readRemoteTask(
scope,
task,
unansweredRemoteTaskRequests(parentProjection.thread).filter(
(request) => request.taskId === task.id,
),
waitTimedOut,
acknowledgeTerminal,
);
}
const childControls = yield* threadManagement
.getThreadRecords(
Expand Down Expand Up @@ -1607,8 +1638,8 @@ const make = Effect.gen(function* () {
Math.max(1, requestedTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS),
);
const waited = yield* waitForTask(scope, taskId, timeoutMs);
if (Option.isSome(waited)) return waited.value;
// As for a local task: once the blocking wait ends, a later result wakes the parent.
if (Option.isSome(waited) && isTerminalTaskStatus(waited.value.status)) return waited.value;
// As for a local task: once the blocking wait ends without a result, a later one wakes the parent.
yield* threadManagement
.dispatch({
type: "delegated_task.wake-policy",
Expand All @@ -1622,7 +1653,7 @@ const make = Effect.gen(function* () {
completionWake: "always",
})
.pipe(Effect.ignoreCause({ log: true }));
return yield* readTask(scope, taskId, true, true);
return yield* readTask(scope, taskId, Option.isNone(waited), true);
});

/**
Expand All @@ -1632,6 +1663,7 @@ const make = Effect.gen(function* () {
const readRemoteTask = Effect.fn("OrchestratorMcpService.readRemoteTask")(function* (
scope: McpThreadInvocationScope,
task: OrchestrationV2Subagent,
unanswered: ReadonlyArray<OrchestrationV2RemoteTaskRequest>,
waitTimedOut: boolean,
acknowledgeTerminal: boolean,
): Effect.fn.Return<OrchestratorMcpDelegateTaskResult, OrchestratorMcpFailure> {
Expand Down Expand Up @@ -1698,9 +1730,8 @@ const make = Effect.gen(function* () {
: null,
latestTerminalSummary: terminal ? task.result : null,
latestTerminalResultContextTransferId: null,
// Its questions and approvals are answered in the environment it runs in.
hasPendingQuestion: false,
pendingApproval: null,
hasPendingQuestion: !terminal && unanswered.some((request) => request.kind === "user_input"),
pendingApproval: terminal ? null : remoteTaskApproval(task, unanswered),
waitTimedOut,
} satisfies OrchestratorMcpDelegateTaskResult;
});
Expand All @@ -1721,12 +1752,10 @@ const make = Effect.gen(function* () {
return Option.some(initial);
}
// One stream per event type, so transcript events never fill a buffer.
// A remote task has no child here; its completion lands on the parent.
// A remote task has no child here; its completion lands on the parent, and so
// does the mirror of what its thread there waits on.
const childThreadId = initial.childThreadId;
const wakeEvents =
childThreadId === null
? TASK_WAKE_EVENTS.filter(({ thread }) => thread === "parent")
: TASK_WAKE_EVENTS;
const wakeEvents = childThreadId === null ? REMOTE_TASK_WAKE_EVENTS : TASK_WAKE_EVENTS;
return yield* Stream.mergeAll(
wakeEvents.map(({ thread, eventType }) =>
threadManagement.streamStoredEventsFrom({
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/mcp/linkOrigin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const fencingLinkOrigin = (caller: Pick<Caller, "scope" | "caller">) => {
return origin !== undefined && linkOriginIsFenced(origin) ? origin : undefined;
};

/** Whether a link paired below full access limits what `caller` may change. */
export const callerIsFenced = (caller: Pick<Caller, "scope" | "caller">) =>
fencingLinkOrigin(caller) !== undefined;

const outsideLink = (origin: OrchestrationV2LinkOrigin, message: string) =>
new OrchestratorMcpFailure({
code: "capability_denied",
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts";
import * as ScheduledTasks from "../../../scheduledTasks/ScheduledTaskService.ts";
import { queuedRunsInDeliveryOrder } from "../../../orchestration-v2/QueuedRunOrder.ts";
import { respondToLinkedRequest, watchLinkedRequests } from "./linkRequests.ts";
import { ThreadToolkit } from "./tools.ts";

function queueEntry(
Expand Down Expand Up @@ -256,6 +257,23 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
}),
),
),
t3_link_requests: McpToolAccess.either(
(input) => input.action === "watch",
McpToolAccess.reads((input) => watchLinkedRequests(input)),
McpToolAccess.writesThreads(
(input) => [input.threadId],
(input) =>
Effect.gen(function* () {
return yield* respondToLinkedRequest({
threadId: input.threadId,
requestId: yield* required(input.requestId, "requestId"),
decision: input.decision,
answers: input.answers,
clientRequestId: yield* required(input.clientRequestId, "clientRequestId"),
});
}),
),
),
t3_queue: McpToolAccess.either(
(input) => input.action === "list" || input.action === "read",
McpToolAccess.reads((input) =>
Expand Down
Loading
Loading