Skip to content

Commit 7e07404

Browse files
feat(server): delegate_task to a linked environment
delegate_task takes target.environmentId (and optionally target.projectId). The child then runs as an ordinary thread in the linked environment, launched through the link with a key derived from the caller's clientRequestId, in the project there with this thread's repository unless one is named. The parent here records the task with delegated_task.remote.request: its node, subagent row and turn item, with remoteChild instead of a local child thread. RemoteDelegation follows the thread there and completes the task with delegated_task.remote.complete, which reuses the parent half of a local finalize, so the parent wakes as it would for a local child. Open remote tasks are followed again at startup. A revoked or expired link fails the task with that reason; any other error backs off, up to five minutes. task_status and mode=wait answer from the row, task_cancel interrupts the thread there and completes the task as cancelled, and restart recovery no longer treats a remote task as abandoned provider work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f9da197 commit 7e07404

35 files changed

Lines changed: 2391 additions & 112 deletions

‎apps/mobile/src/features/threads/SubagentRow.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type SubagentRowSubagent = Pick<
2626
| "status"
2727
| "progress"
2828
| "result"
29+
| "remoteChild"
2930
>;
3031

3132
/**
@@ -37,8 +38,10 @@ export function SubagentRow(props: {
3738
readonly environmentId: EnvironmentId;
3839
readonly subagent: SubagentRowSubagent;
3940
readonly elapsed: ReactNode;
41+
/** The task runs in a linked environment this app is connected to. */
42+
readonly canOpenRemote?: boolean;
4043
}) {
41-
const presentation = resolveSubagentRowPresentation(props.subagent);
44+
const presentation = resolveSubagentRowPresentation(props.subagent, props.canOpenRemote);
4245
const detail = subagentCardDetail(presentation.detail);
4346
return (
4447
<View className="flex-row gap-3">
@@ -69,6 +72,11 @@ export function SubagentRow(props: {
6972
>
7073
{presentation.statusLabel}
7174
</Text>
75+
{presentation.runsOn !== null ? (
76+
<Text numberOfLines={1} className="shrink text-xs text-foreground-muted">
77+
on {presentation.runsOn}
78+
</Text>
79+
) : null}
7280
</View>
7381
{props.elapsed}
7482
{presentation.canOpenThread ? (

‎apps/mobile/src/features/threads/ThreadAgentsSheet.tsx‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { AppText as Text } from "../../components/AppText";
2020
import { useUniwindTheme } from "../../lib/useUniwindTheme";
2121
import { environmentThreadDetails } from "../../state/threads";
2222
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
23+
import { useEnvironments } from "../../state/environments";
2324
import { SubagentRow } from "./SubagentRow";
25+
import { subagentThreadTarget } from "./threadAgentsPresentation";
2426

2527
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
2628

@@ -39,14 +41,14 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
3941
const subagents = turn?.subagents ?? [];
4042
const hasLiveAgent = (turn?.liveCount ?? 0) > 0;
4143

42-
const openChildThread = (childThreadId: ThreadId) => {
44+
const openChildThread = (child: { environmentId: EnvironmentId; threadId: ThreadId }) => {
4345
void Haptics.selectionAsync();
4446
// Replace rather than push: the sheet is a leaf, and the child thread
4547
// belongs in the workspace stack where Home's back button expects it.
4648
navigation.dispatch(
4749
StackActions.replace("Thread", {
48-
environmentId: target.environmentId,
49-
threadId: childThreadId,
50+
environmentId: child.environmentId,
51+
threadId: child.threadId,
5052
}),
5153
);
5254
};
@@ -122,26 +124,34 @@ function AgentRow(props: {
122124
readonly environmentId: EnvironmentId;
123125
readonly subagent: OrchestrationV2Subagent;
124126
readonly tickSeconds: boolean;
125-
readonly onOpen: (childThreadId: ThreadId) => void;
127+
readonly onOpen: (child: { environmentId: EnvironmentId; threadId: ThreadId }) => void;
126128
}) {
127129
const { subagent } = props;
128-
const childThreadId = subagent.childThreadId;
130+
const { presentationById } = useEnvironments();
131+
const target = subagentThreadTarget(subagent, props.environmentId, (id) =>
132+
presentationById.has(id),
133+
);
129134

130135
const row = (
131136
<View className="border-b border-border py-3.5">
132137
<SubagentRow
133138
environmentId={props.environmentId}
134139
subagent={subagent}
140+
canOpenRemote={target !== null}
135141
elapsed={<AgentElapsed subagent={subagent} tickSeconds={props.tickSeconds} />}
136142
/>
137143
</View>
138144
);
139145

140-
if (childThreadId === null) {
146+
if (target === null) {
141147
return (
142148
<View
143149
accessible
144-
accessibilityHint="Provider-managed agent. Its work appears in the transcript."
150+
accessibilityHint={
151+
subagent.remoteChild === undefined
152+
? "Provider-managed agent. Its work appears in the transcript."
153+
: `Runs on ${subagent.remoteChild.label}. Connect to it to open its thread.`
154+
}
145155
>
146156
{row}
147157
</View>
@@ -152,7 +162,7 @@ function AgentRow(props: {
152162
<Pressable
153163
accessibilityRole="link"
154164
accessibilityHint="Opens this agent's thread"
155-
onPress={() => props.onOpen(childThreadId)}
165+
onPress={() => props.onOpen(target)}
156166
className="active:opacity-70"
157167
>
158168
{row}

‎apps/mobile/src/features/threads/ThreadRouteScreen.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,12 @@ function ThreadRouteContent(
504504
const headerSubtitle = [
505505
selectedThreadProject?.title ?? null,
506506
selectedEnvironmentConnection?.environmentLabel ?? null,
507-
selectedThread?.linkOrigin ? `From ${selectedThread.linkOrigin.label}` : null,
507+
// A linked environment's delegated task names its parent; other linked work its origin.
508+
selectedThread?.delegatedFrom
509+
? `Subagent of ${selectedThread.delegatedFrom.title}`
510+
: selectedThread?.linkOrigin
511+
? `From ${selectedThread.linkOrigin.label}`
512+
: null,
508513
]
509514
.filter(Boolean)
510515
.join(" · ");

‎apps/mobile/src/features/threads/thread-subagent-group.tsx‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import type { ThreadFeedActivity } from "../../lib/threadActivity";
1919
import { serverEnvironment } from "../../state/server";
2020
import { environmentThreadDetails } from "../../state/threads";
2121
import { subagentCardElapsed } from "./subagent-card-presentation";
22+
import { useEnvironments } from "../../state/environments";
2223
import { SubagentRow } from "./SubagentRow";
24+
import { subagentThreadTarget } from "./threadAgentsPresentation";
2325
import { WorkLogBlock } from "./work-log-layout";
2426

2527
type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>;
@@ -78,6 +80,7 @@ export function ThreadSubagentGroup(props: {
7880
environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, members[0]!.threadId)),
7981
(thread) => thread?.projection.subagents,
8082
);
83+
const { presentationById } = useEnvironments();
8184
const agents = members.map((item) => {
8285
const live = liveAgents?.find((agent) => agent.id === item.subagentId);
8386
return {
@@ -147,24 +150,31 @@ export function ThreadSubagentGroup(props: {
147150
{!grouped || expanded ? (
148151
<View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1">
149152
{agents.map((agent) => {
150-
const threadId = agent.childThreadId;
153+
// A task in a linked environment opens there, if this app is connected to it.
154+
const target = subagentThreadTarget(agent, props.environmentId, (id) =>
155+
presentationById.has(id),
156+
);
151157
return (
152158
<Pressable
153159
key={agent.item.id}
154160
accessible
155-
accessibilityRole={threadId === null ? undefined : "link"}
161+
accessibilityRole={target === null ? undefined : "link"}
156162
accessibilityHint={
157-
threadId === null ? "Provider-managed agent" : "Opens this agent's thread"
163+
target !== null
164+
? "Opens this agent's thread"
165+
: agent.remoteChild !== undefined
166+
? `Runs on ${agent.remoteChild.label}`
167+
: "Provider-managed agent"
158168
}
159-
disabled={threadId === null}
169+
disabled={target === null}
160170
onPress={() => {
161171
// Push, not navigate: navigate reuses this Thread route, so back
162172
// would skip the parent thread.
163-
if (threadId !== null)
173+
if (target !== null)
164174
navigation.dispatch(
165175
StackActions.push("Thread", {
166-
environmentId: String(props.environmentId),
167-
threadId: String(threadId),
176+
environmentId: String(target.environmentId),
177+
threadId: String(target.threadId),
168178
}),
169179
);
170180
}}
@@ -173,6 +183,7 @@ export function ThreadSubagentGroup(props: {
173183
<SubagentRow
174184
environmentId={props.environmentId}
175185
subagent={agent}
186+
canOpenRemote={target !== null}
176187
elapsed={<SubagentElapsed agents={[agent]} />}
177188
/>
178189
</Pressable>

‎apps/mobile/src/features/threads/threadAgentsPresentation.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vite-plus/test";
22

3-
import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
3+
import { resolveSubagentRowPresentation, subagentThreadTarget } from "./threadAgentsPresentation";
44

55
const base = {
66
title: null,
@@ -59,6 +59,31 @@ describe("resolveSubagentRowPresentation", () => {
5959
);
6060
});
6161

62+
it("opens a task in a linked environment there, only when this app knows it", () => {
63+
const remote = {
64+
...base,
65+
childThreadId: null,
66+
remoteChild: {
67+
environmentId: "environment-box" as never,
68+
threadId: "thread-on-box" as never,
69+
label: "Box",
70+
},
71+
};
72+
expect(resolveSubagentRowPresentation(remote).runsOn).toBe("Box");
73+
expect(resolveSubagentRowPresentation(remote, false).canOpenThread).toBe(false);
74+
expect(resolveSubagentRowPresentation(remote, true).canOpenThread).toBe(true);
75+
const here = "environment-here" as never;
76+
expect(subagentThreadTarget(remote, here, () => false)).toBeNull();
77+
expect(subagentThreadTarget(remote, here, (id) => id === "environment-box")).toEqual({
78+
environmentId: "environment-box",
79+
threadId: "thread-on-box",
80+
});
81+
expect(subagentThreadTarget(base, here, () => false)).toEqual({
82+
environmentId: here,
83+
threadId: "thread-child",
84+
});
85+
});
86+
6287
it("uses the status label when there is nothing to report yet", () => {
6388
const row = resolveSubagentRowPresentation({ ...base, status: "pending" });
6489

‎apps/mobile/src/features/threads/threadAgentsPresentation.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
subagentDetailPreview,
44
} from "@t3tools/client-runtime/state/subagent-display";
55
import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
6-
import type { OrchestrationV2Subagent } from "@t3tools/contracts";
6+
import type { EnvironmentId, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts";
77

88
const PROMPT_TITLE_LIMIT = 80;
99

@@ -18,6 +18,27 @@ export interface SubagentRowPresentation {
1818
readonly live: boolean;
1919
/** Provider-native tasks have no thread of their own to open. */
2020
readonly canOpenThread: boolean;
21+
/** The linked environment the task runs in, when it runs in one. */
22+
readonly runsOn: string | null;
23+
}
24+
25+
/**
26+
* Where an agent's thread opens: here, or in the linked environment that runs
27+
* it. Null when there is no thread, or this app is not connected to that
28+
* environment.
29+
*/
30+
export function subagentThreadTarget(
31+
subagent: Pick<OrchestrationV2Subagent, "childThreadId" | "remoteChild">,
32+
environmentId: EnvironmentId,
33+
isKnownEnvironment: (environmentId: EnvironmentId) => boolean,
34+
): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null {
35+
if (subagent.childThreadId !== null) {
36+
return { environmentId, threadId: subagent.childThreadId };
37+
}
38+
const remote = subagent.remoteChild;
39+
return remote !== undefined && isKnownEnvironment(remote.environmentId)
40+
? { environmentId: remote.environmentId, threadId: remote.threadId }
41+
: null;
2142
}
2243

2344
function rowTitle(subagent: Pick<OrchestrationV2Subagent, "title" | "prompt">): string {
@@ -60,8 +81,9 @@ function rowStatusLabel(status: OrchestrationV2Subagent["status"]): string {
6081
export function resolveSubagentRowPresentation(
6182
subagent: Pick<
6283
OrchestrationV2Subagent,
63-
"title" | "prompt" | "status" | "progress" | "result" | "childThreadId"
84+
"title" | "prompt" | "status" | "progress" | "result" | "childThreadId" | "remoteChild"
6485
>,
86+
canOpenRemote = false,
6587
): SubagentRowPresentation {
6688
const live = isActiveSubagentStatus(subagent.status);
6789
return {
@@ -70,6 +92,8 @@ export function resolveSubagentRowPresentation(
7092
statusLabel: rowStatusLabel(subagent.status),
7193
tone: rowTone(subagent.status),
7294
live,
73-
canOpenThread: subagent.childThreadId !== null,
95+
canOpenThread:
96+
subagent.childThreadId !== null || (subagent.remoteChild !== undefined && canOpenRemote),
97+
runsOn: subagent.remoteChild?.label ?? null,
7498
};
7599
}

0 commit comments

Comments
 (0)