+ {approval.remoteEnvironmentLabel ? (
+
+ Delegated task on {approval.remoteEnvironmentLabel}
+
+ ) : null}
{approval.appName ? (
{approval.appName}
) : null}
diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts
index e859e816074e..ed09032881c4 100644
--- a/apps/web/src/components/chat/useComposerContextImport.ts
+++ b/apps/web/src/components/chat/useComposerContextImport.ts
@@ -35,6 +35,7 @@ import {
terminalContextDraftFromRecord,
terminalContextRecord,
terminalContextReference,
+ pastedContextRecordFits,
} from "~/lib/composerContextRecords";
import { assetEnvironment } from "~/state/assets";
import { copiedComposerAttachmentFile } from "~/lib/copiedComposerAttachments";
@@ -42,6 +43,7 @@ import { countQuestionAttachments, questionAttachmentDraftId } from "../../quest
import { elementContextToPreviewAnnotation } from "../../lib/elementContext";
import { orchestrationEnvironment } from "~/state/orchestration";
import { pendingDraftWork } from "./pendingDraftWork";
+import type { ImportedContextIds } from "../composerInlineTokenPaste";
import { randomUUID } from "~/lib/utils";
import { readPreparedConnection } from "~/state/session";
import { resolveAssetUrl } from "~/assets/assetUrls";
@@ -83,6 +85,7 @@ export function useComposerContextImport(input: {
readonly attachmentTargetKey: string;
readonly attachmentTargetKeyRef: RefObject
;
readonly questionAttachmentTarget: DraftId | null;
+ readonly supportsQuestionAttachments: boolean;
readonly pendingUserInputs: ReadonlyArray;
readonly composerContextRecords: ComposerDraftContextRecords;
readonly composerImages: ReadonlyArray;
@@ -100,6 +103,7 @@ export function useComposerContextImport(input: {
attachmentTargetKey,
attachmentTargetKeyRef,
questionAttachmentTarget,
+ supportsQuestionAttachments,
pendingUserInputs,
composerContextRecords,
composerImages,
@@ -271,10 +275,14 @@ export function useComposerContextImport(input: {
records: ReadonlyArray,
sourceEnvironmentId: EnvironmentId | null,
threadDraftTarget = attachmentDraftTarget,
- ): ReadonlyMap => {
- const rewritten = new Map();
+ ): ImportedContextIds => {
+ const rewritten = new Map();
const dependentAttachmentLocalIds = new Map();
const skippedDependentAttachmentIds = new Set();
+ const target = {
+ answersQuestion: questionAttachmentTarget !== null,
+ takesAttachments: supportsQuestionAttachments,
+ };
// Resolve annotations before their dependent screenshot records even if a foreign
// clipboard producer emitted the records in a different order.
const orderedRecords = records.toSorted((left, right) =>
@@ -336,6 +344,10 @@ export function useComposerContextImport(input: {
continue;
}
const conflicts = existing !== undefined;
+ if (!pastedContextRecordFits(record.kind, target)) {
+ rewritten.set(record.contextId, null);
+ continue;
+ }
switch (record.kind) {
case "terminal": {
const threadId = activeThread?.id ?? activeThreadId;
@@ -388,9 +400,7 @@ export function useComposerContextImport(input: {
break;
}
case "environment": {
- // A machine means the same machine from any thread, so a pasted one is kept,
- // but not in an answer to an agent's question, which carries no machines.
- if (questionAttachmentTarget !== null) break;
+ // A machine means the same machine from any thread, so a pasted one is kept.
addComposerDraftEnvironmentContexts(composerDraftTarget, [record], {
appendReference: false,
});
@@ -416,9 +426,7 @@ export function useComposerContextImport(input: {
}
return rewritten;
};
- const importContextFragment = (
- fragment: ComposerContextClipboardFragment,
- ): ReadonlyMap =>
+ const importContextFragment = (fragment: ComposerContextClipboardFragment): ImportedContextIds =>
importContextRecords(fragment.records, fragment.source.environmentId);
const importContextFragmentStable = useStableCallback(importContextFragment);
diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts
index a5fa30ffe15a..f0794998bfab 100644
--- a/apps/web/src/components/chat/useComposerSend.ts
+++ b/apps/web/src/components/chat/useComposerSend.ts
@@ -211,7 +211,7 @@ interface ComposerSendInput {
readonly needsLoadBalancing: boolean;
readonly loadBalancing: DraftEnvironmentRouting["loadBalancing"];
readonly readPendingEnvironmentChange: DraftEnvironmentRouting["readPendingEnvironmentChange"];
- readonly pendingApprovals: PendingThreadRequests["pendingApprovals"];
+ readonly pendingApprovals: PendingThreadRequests["composerApprovals"];
readonly respondingRequestIds: PendingThreadRequests["respondingRequestIds"];
readonly activePendingProgress: PendingThreadRequests["activePendingProgress"];
readonly onAdvanceActivePendingUserInput: PendingThreadRequests["onAdvanceActivePendingUserInput"];
diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts
index 6f092cd2f008..4a62ac3349d1 100644
--- a/apps/web/src/components/chat/useComposerStash.ts
+++ b/apps/web/src/components/chat/useComposerStash.ts
@@ -19,6 +19,10 @@ import {
useComposerDraftStore,
} from "../../composerDraftStore";
import { type ComposerPromptEditorHandle } from "../ComposerPromptEditorTiptap";
+import {
+ type ImportedContextIds,
+ rewriteImportedContextReferences,
+} from "../composerInlineTokenPaste";
import {
MAX_STASH_ENTRIES,
type PromptStashEntry,
@@ -29,7 +33,6 @@ import { type PendingUserInput } from "../../session-logic";
import { type ReviewCommentContext } from "../../reviewCommentContext";
import { collapseExpandedComposerCursor } from "../../composer-logic";
import { compressImageForStash } from "../../lib/imageCompression";
-import { formatInlineContextReference } from "~/lib/composerContextReferences";
import { isCommandPaletteOpen } from "../../commandPaletteBus";
import { pendingDraftWork } from "./pendingDraftWork";
import {
@@ -45,7 +48,6 @@ import {
verifyStashedAttachmentUpload,
} from "../../lib/attachmentUploadQueue";
import { remainingComposerAttachmentSlots } from "./composerThreadImport";
-import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import { resizeSnapShotSource } from "../../lib/snapShotSource";
import { registerCommands } from "../../shortcuts/dispatcher";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../../terminalUiStateStore";
@@ -94,7 +96,7 @@ export function useComposerStash(input: {
records: ReadonlyArray,
sourceEnvironmentId: EnvironmentId | null,
threadDraftTarget?: ChatComposerProps["composerDraftTarget"],
- ) => ReadonlyMap;
+ ) => ImportedContextIds;
readonly expandMobileComposer: () => void;
}) {
const {
@@ -203,19 +205,12 @@ export function useComposerStash(input: {
});
}
- const rewrittenContextIds = entry.records
- ? importContextRecords(entry.records, null, composerDraftTarget)
- : new Map();
- const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => {
- const contextId = rewrittenContextIds.get(reference.contextId);
- return contextId
- ? formatInlineContextReference({
- ...reference,
- contextId,
- kind: reference.kind === "element" ? "preview-annotation" : reference.kind,
- })
- : reference.source;
- });
+ const restoredPrompt = entry.records
+ ? rewriteImportedContextReferences(
+ entry.prompt,
+ importContextRecords(entry.records, null, composerDraftTarget),
+ )
+ : entry.prompt;
const currentPrompt = promptRef.current;
const nextPrompt =
restoredPrompt.length === 0
diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts
index 35e4cc5f0855..dfa1cd20c2f9 100644
--- a/apps/web/src/components/chat/usePendingThreadRequests.ts
+++ b/apps/web/src/components/chat/usePendingThreadRequests.ts
@@ -12,6 +12,7 @@ import {
derivePendingThreadRequests,
onThreadRequestsResolved,
pruneResolvedUserInputDrafts,
+ redirectableApprovals,
seedUserInputDraftAnswers,
userInputDraftKey,
} from "@t3tools/client-runtime/state/thread-requests";
@@ -254,6 +255,7 @@ export function usePendingThreadRequests(input: {
respondingUserInputRequestIds.includes(activePendingUserInput.requestId)
: false;
const activePendingApproval = pendingApprovals[0] ?? null;
+ const composerApprovals = redirectableApprovals(pendingApprovals);
const onRespondToApproval = async (
requestId: RuntimeRequestId,
@@ -487,6 +489,7 @@ export function usePendingThreadRequests(input: {
return {
pendingApprovals,
+ composerApprovals,
pendingUserInputs,
activePendingApproval,
activePendingUserInput,
diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts
new file mode 100644
index 000000000000..13a71fde5e45
--- /dev/null
+++ b/apps/web/src/components/composerInlineTokenPaste.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { rewriteImportedContextReferences } from "./composerInlineTokenPaste";
+
+describe("rewriteImportedContextReferences", () => {
+ const pasted =
+ "See  and [notes.md](t3-context://v1/file/file_b) in [build](t3-context://v1/terminal/term_c)";
+
+ it.each([
+ [
+ "points an imported record at its new id",
+ new Map([["img_a", "img_z"]]),
+ "See  and [notes.md](t3-context://v1/file/file_b) in [build](t3-context://v1/terminal/term_c)",
+ ],
+ [
+ "pastes a declined record as its label",
+ new Map([
+ ["img_a", null],
+ ["file_b", null],
+ ]),
+ "See shot.png and notes.md in [build](t3-context://v1/terminal/term_c)",
+ ],
+ ])("%s", (_, importedIds, expected) => {
+ expect(rewriteImportedContextReferences(pasted, importedIds)).toBe(expected);
+ });
+});
diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts
index 32722ad6a957..f78cf11e0dff 100644
--- a/apps/web/src/components/composerInlineTokenPaste.ts
+++ b/apps/web/src/components/composerInlineTokenPaste.ts
@@ -37,28 +37,34 @@ export function readPastedComposerContext(
};
}
+// null marks a record the target declined; its reference pastes as plain text.
+export type ImportedContextIds = ReadonlyMap;
+
+export function rewriteImportedContextReferences(
+ text: string,
+ importedIds: ImportedContextIds,
+): string {
+ if (importedIds.size === 0) return text;
+ return replaceComposerContextReferences(text, (occurrence) => {
+ const nextId = importedIds.get(occurrence.contextId);
+ if (nextId === null) return occurrence.label;
+ return nextId
+ ? formatComposerContextReference({
+ ...occurrence,
+ contextId: ComposerContextId.make(nextId),
+ kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind,
+ })
+ : occurrence.source;
+ });
+}
+
export function importPastedComposerText(
clipboardData: Pick,
- importContextFragment?: (
- fragment: ComposerContextClipboardFragment,
- ) => ReadonlyMap,
+ importContextFragment?: (fragment: ComposerContextClipboardFragment) => ImportedContextIds,
): string {
const pastedText = clipboardData.getData("text/plain");
const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null;
- const rewrittenIds =
- fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null;
- const text =
- rewrittenIds && rewrittenIds.size > 0
- ? replaceComposerContextReferences(pastedText, (occurrence) => {
- const nextId = rewrittenIds.get(occurrence.contextId);
- return nextId
- ? formatComposerContextReference({
- ...occurrence,
- contextId: ComposerContextId.make(nextId),
- kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind,
- })
- : occurrence.source;
- })
- : pastedText;
- return text;
+ return fragment && fragment.records.length > 0
+ ? rewriteImportedContextReferences(pastedText, importContextFragment!(fragment))
+ : pastedText;
}
diff --git a/apps/web/src/lib/composerContextRecords.test.ts b/apps/web/src/lib/composerContextRecords.test.ts
index f612a302ac8c..c64d1d8d0f84 100644
--- a/apps/web/src/lib/composerContextRecords.test.ts
+++ b/apps/web/src/lib/composerContextRecords.test.ts
@@ -20,6 +20,7 @@ import {
buildMessageContext,
composerContextImportLookupIds,
isSameComposerContextPayload,
+ pastedContextRecordFits,
previewAnnotationContextRecord,
previewAnnotationFromRecord,
pullRequestContextKindLabel,
@@ -67,6 +68,38 @@ const annotation: PreviewAnnotationPayload = {
};
describe("composerContextRecords", () => {
+ it.each([
+ ["image", "a message", { answersQuestion: false, takesAttachments: false }, true],
+ ["environment", "a message", { answersQuestion: false, takesAttachments: false }, true],
+ [
+ "file",
+ "a question that takes files",
+ { answersQuestion: true, takesAttachments: true },
+ true,
+ ],
+ [
+ "image",
+ "a question that takes none",
+ { answersQuestion: true, takesAttachments: false },
+ false,
+ ],
+ [
+ "file",
+ "a question that takes none",
+ { answersQuestion: true, takesAttachments: false },
+ false,
+ ],
+ [
+ "thread",
+ "a question that takes none",
+ { answersQuestion: true, takesAttachments: false },
+ true,
+ ],
+ ["environment", "any question", { answersQuestion: true, takesAttachments: true }, false],
+ ] as const)("a pasted %s record fits %s: %s", (kind, _, target, fits) => {
+ expect(pastedContextRecordFits(kind, target)).toBe(fits);
+ });
+
it("copies only ready or persisted server-side attachment IDs", () => {
const environmentId = EnvironmentId.make("env");
const image = {
diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts
index c4f123f73dcc..b35e9983bdbb 100644
--- a/apps/web/src/lib/composerContextRecords.ts
+++ b/apps/web/src/lib/composerContextRecords.ts
@@ -347,6 +347,16 @@ export function asKnownContextRecord(
return record;
}
+/** An answer to an agent's question carries no machines, and files only when the question takes them. */
+export function pastedContextRecordFits(
+ kind: KnownComposerContextRecord["kind"],
+ target: { readonly answersQuestion: boolean; readonly takesAttachments: boolean },
+): boolean {
+ if (!target.answersQuestion) return true;
+ if (kind === "environment") return false;
+ return target.takesAttachments || (kind !== "image" && kind !== "file");
+}
+
export function composerContextImportLookupIds(
record: KnownComposerContextRecord,
): ReadonlyArray {
diff --git a/docs/internals/remote.md b/docs/internals/remote.md
index d88d2973f4e4..b78443c148ca 100644
--- a/docs/internals/remote.md
+++ b/docs/internals/remote.md
@@ -133,6 +133,17 @@ internal command reuses the parent half of a local finalize, so the parent
wakes the same way. Open remote tasks are followed again at startup, which is
also what keeps restart recovery from treating them as abandoned provider work.
+The user's phone or browser may reach only the parent's environment, so the
+follower also mirrors what the task's thread there waits on onto the parent's
+record (`remoteTaskRequests`, through `t3_link_requests`). The parent's shell
+reports it as its pending request, and an answer there takes the normal
+`runtime-request.respond` path; the follower relays it through the link, which
+holds it to the link's access and fence like any other write. An answer refused
+there while the request still waits is asked for again on the parent. A message
+sent on the parent redirects only its own agent, so it never declines a mirrored
+approval. A peer without `t3_link_requests` keeps its requests to itself, as
+before.
+
The link is routing, not isolation: an agent the link starts runs as the
receiving environment's user. **A full-access link is therefore not fenced**:
its agents can already do anything that user can, so the fence would only stop
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index 835476864a1f..d9649c0c6882 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -297,7 +297,10 @@ there as an ordinary thread, in the project with the same repository (or the
one `target.projectId` names), and this thread wakes with its result when it
ends, as for any delegated task. It keeps following the task across restarts.
If the other machine revokes the link, or the link is removed here, the task
-fails with that reason.
+fails with that reason. When the task asks a question or needs an approval,
+this thread shows it, so you can answer from any device connected to this
+machine. A machine on an older T3 Code version keeps those requests to itself;
+answer them there.
## Manage or revoke access
diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts
index a53494ebc592..069bbe21daef 100644
--- a/packages/client-runtime/src/state/threadDetail.ts
+++ b/packages/client-runtime/src/state/threadDetail.ts
@@ -369,8 +369,11 @@ export function createEnvironmentThreadDetailAtoms(
).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-worktree:${key}`)),
);
const pendingRequestsAtomFamily = Atom.family((key: string) => {
- let previous: Pick | null =
- null;
+ let previous:
+ | (Pick & {
+ readonly thread: Pick;
+ })
+ | null = null;
let value: PendingThreadRequests | null = null;
return Atom.make((get) => {
const projection = Option.getOrNull(get(threadStateValueAtomFamily(key)).data);
@@ -391,12 +394,24 @@ export function createEnvironmentThreadDetailAtoms(
(item.type === "approval_request" || item.type === "user_input_request") &&
ids.has(item.requestId),
);
+ const remoteTaskRequests = projection.thread.remoteTaskRequests;
+ const subagents =
+ remoteTaskRequests === undefined
+ ? []
+ : projection.subagents.filter((task) => task.remoteChild !== undefined);
if (
previous === null ||
!arrayElementsEqual(previous.runtimeRequests, runtimeRequests) ||
- !arrayElementsEqual(previous.turnItems, turnItems)
+ !arrayElementsEqual(previous.turnItems, turnItems) ||
+ !arrayElementsEqual(previous.subagents, subagents) ||
+ previous.thread.remoteTaskRequests !== remoteTaskRequests
) {
- previous = { runtimeRequests, turnItems };
+ previous = {
+ runtimeRequests,
+ turnItems,
+ subagents,
+ thread: remoteTaskRequests === undefined ? {} : { remoteTaskRequests },
+ };
value = derivePendingThreadRequests(previous);
}
return value;
diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts
index d5d2c4ec16d8..0dd6f8dad279 100644
--- a/packages/client-runtime/src/state/threadRequests.test.ts
+++ b/packages/client-runtime/src/state/threadRequests.test.ts
@@ -3,6 +3,7 @@ import {
NodeId,
ProviderSessionId,
RuntimeRequestId,
+ ThreadId,
TurnItemId,
type OrchestrationV2ThreadProjection,
} from "@t3tools/contracts";
@@ -13,6 +14,7 @@ import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts";
import {
createQuestionHistoryProjector,
derivePendingThreadRequests,
+ redirectableApprovals,
declinePendingApprovals,
sendApprovalFeedback,
makeThreadRequestsResolvedAnnouncer,
@@ -316,6 +318,73 @@ describe("pending v2 questions", () => {
};
expect(derivePendingThreadRequests(live).userInputs[0]?.dismissible).toBe(false);
});
+ it("lists what a task in a linked environment waits on until the user answers it here", () => {
+ const remote = {
+ runtimeRequests: [],
+ turnItems: [],
+ subagents: [
+ {
+ id: NodeId.make("remote-task"),
+ remoteChild: {
+ environmentId: EnvironmentId.make("environment-box"),
+ threadId: ThreadId.make("thread-on-the-box"),
+ label: "Box",
+ },
+ },
+ ],
+ thread: {
+ remoteTaskRequests: [
+ {
+ requestId: RuntimeRequestId.make("remote-approval"),
+ taskId: NodeId.make("remote-task"),
+ kind: "command" as const,
+ createdAt: v2Now,
+ detail: "pnpm test",
+ },
+ {
+ requestId: RuntimeRequestId.make("remote-question"),
+ taskId: NodeId.make("remote-task"),
+ kind: "user_input" as const,
+ createdAt: v2Now,
+ questions: [{ id: "size", header: "Size", question: "Which size?", options: [] }],
+ },
+ {
+ requestId: RuntimeRequestId.make("remote-answered"),
+ taskId: NodeId.make("remote-task"),
+ kind: "command" as const,
+ createdAt: v2Now,
+ response: { decision: "accept" as const },
+ },
+ ],
+ },
+ };
+ const pending = derivePendingThreadRequests(remote);
+ expect(pending.approvals).toEqual([
+ {
+ requestId: "remote-approval",
+ requestKind: "command",
+ createdAt: "2026-06-20T00:00:00.000Z",
+ detail: "pnpm test",
+ responseCapability: "live",
+ remoteTaskId: "remote-task",
+ remoteEnvironmentLabel: "Box",
+ },
+ ]);
+ expect(redirectableApprovals(pending.approvals)).toEqual([]);
+ expect(pending.userInputs).toEqual([
+ {
+ requestId: "remote-question",
+ createdAt: "2026-06-20T00:00:00.000Z",
+ questions: [
+ { id: "size", header: "Size", question: "Which size?", options: [], multiSelect: false },
+ ],
+ responseCapability: "live",
+ dismissible: false,
+ remoteTaskId: "remote-task",
+ remoteEnvironmentLabel: "Box",
+ },
+ ]);
+ });
});
it("restores old text answers without mutating history or replacing unchanged rows", () => {
diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts
index e089eb36d08c..ec3f42f3192e 100644
--- a/packages/client-runtime/src/state/threadRequests.ts
+++ b/packages/client-runtime/src/state/threadRequests.ts
@@ -1,14 +1,18 @@
-import type {
- EnvironmentId,
- ThreadId,
- OrchestrationV2ThreadProjection,
- OrchestrationV2RuntimeRequest,
- OrchestrationV2UserInputQuestion,
- ProviderApprovalFullAccessOverride,
- ProviderApprovalOption,
- ProviderApprovalDecision,
- ProviderRequestKind,
- RuntimeRequestId,
+import {
+ unansweredRemoteTaskRequests,
+ type EnvironmentId,
+ type NodeId,
+ type ThreadId,
+ type OrchestrationV2AppThread,
+ type OrchestrationV2Subagent,
+ type OrchestrationV2ThreadProjection,
+ type OrchestrationV2RuntimeRequest,
+ type OrchestrationV2UserInputQuestion,
+ type ProviderApprovalFullAccessOverride,
+ type ProviderApprovalOption,
+ type ProviderApprovalDecision,
+ type ProviderRequestKind,
+ type RuntimeRequestId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import type { AtomCommandResult } from "./runtime.ts";
@@ -31,6 +35,14 @@ export async function sendApprovalFeedback(
return (await respond(approval.requestId, "decline", feedback.trim()))?._tag === "Success";
}
+/** A message sent here redirects this thread's agent, never a task waiting in another environment. */
+export const redirectableApprovals = (
+ approvals: ReadonlyArray,
+): ReadonlyArray =>
+ approvals.some((approval) => approval.remoteTaskId !== undefined)
+ ? approvals.filter((approval) => approval.remoteTaskId === undefined)
+ : approvals;
+
/** Declines every approval a redirect replaces; any one still pending would block the agent. */
export async function declinePendingApprovals(
approvals: ReadonlyArray,
@@ -55,6 +67,8 @@ export interface ThreadPendingApproval {
readonly options?: ReadonlyArray;
readonly responseCapability: "live" | "not_resumable";
readonly supportsFeedback?: boolean;
+ readonly remoteTaskId?: NodeId;
+ readonly remoteEnvironmentLabel?: string;
}
export const FULL_ACCESS_OVERRIDE_QUESTION = "Why am I being asked this?";
@@ -81,6 +95,8 @@ export interface ThreadPendingUserInput {
readonly responseCapability: OrchestrationV2RuntimeRequest["responseCapability"]["type"];
readonly responseMode?: "message";
readonly dismissible: boolean;
+ readonly remoteTaskId?: NodeId;
+ readonly remoteEnvironmentLabel?: string;
}
export interface PendingThreadRequests {
@@ -213,16 +229,27 @@ export function makeThreadRequestsResolvedAnnouncer(
};
}
+type WithRemoteTaskRequests = {
+ readonly thread?: Pick;
+ readonly subagents?: ReadonlyArray>;
+};
+
export function pendingRuntimeRequestIds(
- projection: Pick,
+ projection: Pick & WithRemoteTaskRequests,
): RuntimeRequestId[] {
- return projection.runtimeRequests.flatMap((request) =>
- request.status === "pending" ? [request.id] : [],
- );
+ return [
+ ...projection.runtimeRequests.flatMap((request) =>
+ request.status === "pending" ? [request.id] : [],
+ ),
+ ...remoteTaskRequestsOf(projection).map((request) => request.requestId),
+ ];
}
+const remoteTaskRequestsOf = (projection: WithRemoteTaskRequests) =>
+ projection.thread === undefined ? [] : unansweredRemoteTaskRequests(projection.thread);
+
export function derivePendingThreadRequests(
- projection: Pick,
+ projection: Pick & WithRemoteTaskRequests,
): PendingThreadRequests {
const approvals: ThreadPendingApproval[] = [];
const userInputs: ThreadPendingUserInput[] = [];
@@ -276,6 +303,40 @@ export function derivePendingThreadRequests(
});
}
+ for (const request of remoteTaskRequestsOf(projection)) {
+ const createdAt = DateTime.formatIso(request.createdAt);
+ const environmentLabel = projection.subagents?.find((task) => task.id === request.taskId)
+ ?.remoteChild?.label;
+ const remoteTask = {
+ remoteTaskId: request.taskId,
+ ...(environmentLabel === undefined ? {} : { remoteEnvironmentLabel: environmentLabel }),
+ };
+ if (request.kind === "user_input") {
+ userInputs.push({
+ requestId: request.requestId,
+ createdAt,
+ questions: (request.questions ?? []).map((question) => ({
+ ...question,
+ multiSelect: question.multiSelect ?? false,
+ })),
+ responseCapability: "live",
+ dismissible: false,
+ ...remoteTask,
+ });
+ continue;
+ }
+ approvals.push({
+ requestId: request.requestId,
+ requestKind: request.kind,
+ createdAt,
+ ...(request.detail ? { detail: request.detail } : {}),
+ ...(request.reason ? { reason: request.reason } : {}),
+ ...(request.options === undefined ? {} : { options: request.options }),
+ responseCapability: "live",
+ ...remoteTask,
+ });
+ }
+
return { approvals, userInputs };
}
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index 993fb62453d5..6daefedc80ef 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -433,6 +433,71 @@ const LenientThreadOpens = Schema.NullOr(OrchestrationV2ThreadOpens).pipe(
Schema.catchDecoding(() => Effect.succeedSome(null)),
);
+export const OrchestrationV2UserInputQuestion = Schema.Struct({
+ id: TrimmedNonEmptyString,
+ header: TrimmedNonEmptyString,
+ question: TrimmedNonEmptyString,
+ options: Schema.Array(
+ Schema.Struct({
+ label: TrimmedNonEmptyString,
+ description: TrimmedNonEmptyString,
+ value: Schema.optional(Schema.String),
+ preview: Schema.optional(Schema.String),
+ }),
+ ),
+ multiSelect: Schema.optional(Schema.Boolean),
+ allowCustomAnswer: Schema.optional(Schema.Boolean),
+ initialAnswer: Schema.optional(Schema.String),
+ required: Schema.optional(Schema.Boolean),
+});
+export type OrchestrationV2UserInputQuestion = typeof OrchestrationV2UserInputQuestion.Type;
+
+/**
+ * A question or approval a thread waits on, as the environment that delegated
+ * it a task reads it through their link. `runId` is the run there it blocks.
+ */
+export const OrchestrationV2LinkedRequest = Schema.Struct({
+ requestId: RuntimeRequestId,
+ kind: Schema.Union([ProviderRequestKind, Schema.Literal("user_input")]),
+ createdAt: Schema.DateTimeUtc,
+ runId: Schema.optional(RunId),
+ questions: Schema.optional(Schema.Array(OrchestrationV2UserInputQuestion)),
+ detail: Schema.optional(Schema.String),
+ reason: Schema.optional(Schema.String),
+ options: Schema.optional(Schema.Array(ProviderApprovalOption)),
+});
+export type OrchestrationV2LinkedRequest = typeof OrchestrationV2LinkedRequest.Type;
+
+export const OrchestrationV2LinkedRequestJson = OrchestrationV2LinkedRequest.mapFields(
+ (fields) => ({ ...fields, createdAt: Schema.DateTimeUtcFromString }),
+);
+
+/**
+ * A request the thread of a task delegated to a linked environment waits on,
+ * mirrored on the parent here so the user answers it here. `response` is the
+ * answer given here, owed to the thread there until it resolves the request.
+ */
+export const OrchestrationV2RemoteTaskRequest = Schema.Struct({
+ ...OrchestrationV2LinkedRequest.fields,
+ taskId: NodeId,
+ response: Schema.optional(
+ Schema.Struct({
+ decision: Schema.optional(ProviderApprovalDecision),
+ answers: Schema.optional(ProviderUserInputAnswers),
+ }),
+ ),
+});
+export type OrchestrationV2RemoteTaskRequest = typeof OrchestrationV2RemoteTaskRequest.Type;
+
+const OrchestrationV2RemoteTaskRequestJson = OrchestrationV2RemoteTaskRequest.mapFields(
+ (fields) => ({ ...fields, createdAt: Schema.DateTimeUtcFromString }),
+);
+
+export const unansweredRemoteTaskRequests = (
+ thread: Pick,
+): ReadonlyArray =>
+ thread.remoteTaskRequests?.filter((request) => request.response === undefined) ?? [];
+
export const OrchestrationV2AppThread = Schema.Struct({
...OrchestrationV2CreationFields,
id: ThreadId,
@@ -451,6 +516,8 @@ export const OrchestrationV2AppThread = Schema.Struct({
historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin),
linkOrigin: Schema.optional(OrchestrationV2LinkOrigin),
delegatedFrom: Schema.optional(OrchestrationV2DelegatedFrom),
+ /** Questions and approvals of this thread's tasks in linked environments. */
+ remoteTaskRequests: Schema.optional(Schema.Array(OrchestrationV2RemoteTaskRequest)),
lineage: OrchestrationV2AppThreadLineage,
forkedFrom: Schema.NullOr(
Schema.Union([
@@ -1296,25 +1363,6 @@ export const OrchestrationV2PlanStep = Schema.Struct({
});
export type OrchestrationV2PlanStep = typeof OrchestrationV2PlanStep.Type;
-export const OrchestrationV2UserInputQuestion = Schema.Struct({
- id: TrimmedNonEmptyString,
- header: TrimmedNonEmptyString,
- question: TrimmedNonEmptyString,
- options: Schema.Array(
- Schema.Struct({
- label: TrimmedNonEmptyString,
- description: TrimmedNonEmptyString,
- value: Schema.optional(Schema.String),
- preview: Schema.optional(Schema.String),
- }),
- ),
- multiSelect: Schema.optional(Schema.Boolean),
- allowCustomAnswer: Schema.optional(Schema.Boolean),
- initialAnswer: Schema.optional(Schema.String),
- required: Schema.optional(Schema.Boolean),
-});
-export type OrchestrationV2UserInputQuestion = typeof OrchestrationV2UserInputQuestion.Type;
-
const OrchestrationV2PlanArtifactBaseFields = {
id: PlanId,
threadId: ThreadId,
@@ -2209,6 +2257,7 @@ export type OrchestrationV2StoredEvent = typeof OrchestrationV2StoredEvent.Type;
export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields((fields) => ({
...fields,
+ remoteTaskRequests: Schema.optional(Schema.Array(OrchestrationV2RemoteTaskRequestJson)),
createdAt: Schema.DateTimeUtcFromString,
updatedAt: Schema.DateTimeUtcFromString,
archivedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
@@ -3395,6 +3444,30 @@ const OrchestrationV2InternalCommand = Schema.Union([
parentThreadId: ThreadId,
taskId: NodeId,
}),
+ /**
+ * Mirrors what a remote task's thread there waits on now, as its follower read it there,
+ * so the user answers it on the parent here. An answer given here stays until the request
+ * leaves the thread there; a task that has its result mirrors nothing.
+ */
+ Schema.Struct({
+ type: Schema.Literal("delegated_task.remote.requests"),
+ commandId: CommandId,
+ parentThreadId: ThreadId,
+ taskId: NodeId,
+ requests: Schema.Array(OrchestrationV2LinkedRequest),
+ }),
+ /**
+ * The answer given here to a remote task's request could not reach its thread there. The
+ * request is unanswered here again, and the parent shows why.
+ */
+ Schema.Struct({
+ type: Schema.Literal("delegated_task.remote.respond.fail"),
+ commandId: CommandId,
+ parentThreadId: ThreadId,
+ taskId: NodeId,
+ requestId: RuntimeRequestId,
+ message: Schema.String,
+ }),
Schema.Struct({
type: Schema.Literal("checkpoint.rollback.fail"),
commandId: CommandId,
diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts
index f83a0b376233..e8f0eb1d4abb 100644
--- a/packages/provider-core/src/server/subagentProjection.ts
+++ b/packages/provider-core/src/server/subagentProjection.ts
@@ -54,8 +54,10 @@ export function makeSubagentChildThread(input: {
readonly createdBy: OrchestrationV2Actor;
readonly creationSource: OrchestrationV2CreationSource;
}): OrchestrationV2AppThread {
+ // The parent answers its remote tasks' requests; a child has none of its own yet.
+ const { remoteTaskRequests: _remoteTaskRequests, ...parentThread } = input.parentThread;
return {
- ...input.parentThread,
+ ...parentThread,
createdBy: input.createdBy,
creationSource: input.creationSource,
id: input.childThreadId,