authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-10 20:07:41-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 20:07:41-03:00
log818e2be75e1a68bbcac122eac1b6fb815ccf4b73
tree2ca3b968e2fc761d81d21c600a80bf9e4bd852ca
parent9afde1c6dbe00d2b87829732c88789c10f3429c0
signature Signed by PGP key B5690EEEBB952194

fix(server): route Copilot ACP subagent output into subagent threads (#17714)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

5 files changed, 402 insertions(+), 3 deletions(-)

apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+202
......@@ -1,4 +1,6 @@
11import {
2 extractCopilotSubagentUpdate,
3 makeCopilotSubagentRouting,
24 normalizeDevinSessionUpdate,
35 normalizeDevinToolCall,
46 extractDevinSubagentUpdate,
......@@ -1464,6 +1466,206 @@ describe("AcpAdapterV2", () => {
14641466 }).pipe(Effect.provide(layerTest), Effect.scoped),
14651467 );
14661468
1469 it.effect("routes interleaved Copilot subagent output into subagent threads", () =>
1470 Effect.gen(function* () {
1471 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1472 const path = yield* Path.Path;
1473 const mockAgentPath = yield* path.fromFileUrl(
1474 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1475 );
1476 type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"];
1477 let handler: Parameters<Runtime["handleSessionUpdate"]>[0] | undefined;
1478 let sessionEvent:
1479 | ((event: unknown) => Effect.Effect<void, EffectAcpErrors.AcpError>)
1480 | undefined;
1481 const routing = makeCopilotSubagentRouting();
1482 const instanceId = ProviderInstanceId.make("copilot-replay");
1483 const adapter = yield* makeAcpAdapterV2({
1484 instanceId,
1485 selfInvocation: yield* resolveSelfInvocation(),
1486 flavor: {
1487 driver: ACP_TEST_DRIVER,
1488 capabilities: AcpProviderCapabilitiesV2,
1489 registerExtensions: routing.registerExtensions,
1490 normalizeSessionUpdate: routing.normalizeSessionUpdate,
1491 extractSubagentUpdate: extractCopilotSubagentUpdate,
1492 makeRuntime: makeMockRuntime({
1493 childProcessSpawner,
1494 mockAgentPath,
1495 wrapRuntime: (runtime) => ({
1496 ...runtime,
1497 handleSessionUpdate: (next) =>
1498 Effect.sync(() => {
1499 handler = next;
1500 }).pipe(Effect.andThen(runtime.handleSessionUpdate(next))),
1501 handleExtNotification: (method, payload, next) =>
1502 Effect.sync(() => {
1503 if (method === "github.com/copilot/sessionEvent") {
1504 sessionEvent = next as typeof sessionEvent;
1505 }
1506 }).pipe(Effect.andThen(runtime.handleExtNotification(method, payload, next))),
1507 prompt: () =>
1508 Effect.gen(function* () {
1509 assert.isDefined(handler);
1510 assert.isDefined(sessionEvent);
1511 const sessionId = "mock-session-1";
1512 const raw = (type: string, data: object, agentId?: string) =>
1513 sessionEvent!({ sessionId, type, data, ...(agentId ? { agentId } : {}) });
1514 const update = (next: EffectAcpSchema.SessionUpdate) =>
1515 handler!({ sessionId, update: next });
1516 // Copilot sends each chunk's raw delta just before the chunk.
1517 const chunk = (text: string, task?: string) =>
1518 raw("assistant.message_delta", {
1519 deltaContent: text,
1520 ...(task ? { parentToolCallId: task } : {}),
1521 }).pipe(
1522 Effect.andThen(
1523 update({
1524 sessionUpdate: "agent_message_chunk",
1525 content: { type: "text", text },
1526 }),
1527 ),
1528 );
1529 const launch = (toolCallId: string, description: string, mode: string) =>
1530 update({
1531 sessionUpdate: "tool_call",
1532 toolCallId,
1533 title: description,
1534 kind: "other",
1535 status: "pending",
1536 rawInput: {
1537 description,
1538 prompt: `${description}.`,
1539 agent_type: "explore",
1540 mode,
1541 },
1542 });
1543 // Shapes captured from Copilot CLI 1.0.95 with two sync and one
1544 // background subagent launched in one batch.
1545 yield* chunk("Launching ");
1546 yield* launch("task-a", "Describe a.txt", "sync");
1547 yield* launch("task-b", "Describe b.txt", "sync");
1548 yield* launch("task-bg", "List directory", "background");
1549 yield* raw("subagent.started", { toolCallId: "task-a" }, "agent-a");
1550 yield* raw("subagent.started", { toolCallId: "task-b" }, "agent-b");
1551 yield* raw("subagent.started", { toolCallId: "task-bg" }, "agent-bg");
1552 yield* update({
1553 sessionUpdate: "tool_call_update",
1554 toolCallId: "task-bg",
1555 status: "completed",
1556 rawOutput: { content: "Agent started in background with agent_id: agent-bg." },
1557 });
1558 yield* update({
1559 sessionUpdate: "tool_call",
1560 toolCallId: "view-a",
1561 title: "Viewing a.txt",
1562 kind: "read",
1563 status: "completed",
1564 rawInput: { path: "a.txt" },
1565 _meta: { "github.com/copilot": { agentId: "agent-a" } },
1566 });
1567 yield* chunk("`a.txt` says", "task-a");
1568 yield* chunk("`b.txt` has", "task-b");
1569 yield* chunk(" hello.", "task-a");
1570 yield* chunk("a.txt b.txt", "task-bg");
1571 yield* chunk(" three lines.", "task-b");
1572 for (const [task, agent, text] of [
1573 ["task-a", "agent-a", "`a.txt` says hello."],
1574 ["task-b", "agent-b", "`b.txt` has three lines."],
1575 ] as const) {
1576 yield* update({
1577 sessionUpdate: "tool_call_update",
1578 toolCallId: task,
1579 status: "completed",
1580 rawOutput: { content: text },
1581 });
1582 yield* raw("subagent.completed", { toolCallId: task }, agent);
1583 }
1584 yield* raw("subagent.completed", { toolCallId: "task-bg" }, "agent-bg");
1585 yield* chunk("all done.");
1586 return { stopReason: "end_turn" as const };
1587 }),
1588 }),
1589 }),
1590 },
1591 });
1592 const threadId = ThreadId.make("copilot-replay-parent");
1593 const modelSelection = { instanceId, model: "default" };
1594 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1595 runtimeMode: "full-access",
1596 interactionMode: "default",
1597 cwd: process.cwd(),
1598 });
1599 const runtime = yield* adapter.openSession({
1600 threadId,
1601 providerSessionId: ProviderSessionId.make("copilot-replay-session"),
1602 modelSelection,
1603 runtimePolicy,
1604 });
1605 const providerThread = yield* runtime.ensureThread({
1606 threadId,
1607 modelSelection,
1608 runtimePolicy,
1609 });
1610 yield* runtime.startTurn(
1611 makeTurnInput({
1612 threadId,
1613 providerThread,
1614 instanceId,
1615 runtimePolicy,
1616 now: yield* DateTime.now,
1617 }),
1618 );
1619 const events = Array.from(
1620 yield* runtime.events.pipe(
1621 Stream.takeUntil((event) => event.type === "turn.terminal"),
1622 Stream.runCollect,
1623 ),
1624 );
1625 const items = events.flatMap((event) =>
1626 event.type === "turn_item.updated" ? [event.turnItem] : [],
1627 );
1628 const lastText = (thread: string | null | undefined) =>
1629 new Map(
1630 items.flatMap((item) =>
1631 item.threadId === thread && item.type === "assistant_message"
1632 ? [[item.id, item.text] as const]
1633 : [],
1634 ),
1635 );
1636 assert.deepEqual([...lastText(threadId).values()].join(""), "Launching all done.");
1637 const tasks = new Map(
1638 events.flatMap((event) =>
1639 event.type === "subagent.updated"
1640 ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent] as const]
1641 : [],
1642 ),
1643 );
1644 const outputs = Object.fromEntries(
1645 [...tasks].map(([id, task]) => [
1646 id,
1647 [task.status, [...lastText(task.childThreadId).values()].join("")],
1648 ]),
1649 );
1650 assert.deepEqual(outputs, {
1651 "task-a": ["completed", "`a.txt` says hello."],
1652 "task-b": ["completed", "`b.txt` has three lines."],
1653 "task-bg": ["completed", "a.txt b.txt"],
1654 });
1655 assert.isTrue(
1656 items.some(
1657 (item) =>
1658 item.threadId === tasks.get("task-a")?.childThreadId &&
1659 item.type === "dynamic_tool" &&
1660 item.title === "Read a.txt",
1661 ),
1662 );
1663 assert.isFalse(
1664 items.some((item) => item.threadId === threadId && item.type === "dynamic_tool"),
1665 );
1666 }).pipe(Effect.provide(layerTest), Effect.scoped),
1667 );
1668
14671669 it.effect("projects ACP v2 fidelity updates into first-class orchestration items", () =>
14681670 Effect.gen(function* () {
14691671 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
packages/provider-acp-registry/src/server/adapter.ts+21-2
......@@ -3,6 +3,11 @@ import {
33 normalizeDevinToolCall,
44 extractDevinSubagentUpdate,
55} from "./devinAcp.ts";
6import {
7 copilotClientCapabilitiesMeta,
8 extractCopilotSubagentUpdate,
9 makeCopilotSubagentRouting,
10} from "./copilotAcp.ts";
611import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts";
712import { AcpRegistrySettings } from "../settings.ts";
813import * as HostProcess from "@t3tools/shared/HostProcess";
......@@ -73,7 +78,7 @@ export interface AcpRegistryAdapterV2Options {
7378// Agent-specific behavior does not belong here: an agent that needs it gets a
7479// dedicated driver (as Grok and Antigravity have). The few exceptions below
7580// predate that rule and are small presentation hooks, not permission or tool
76// behavior.
81// behavior. Copilot's subagent routing was approved later on the same terms.
7782//
7883// Agents changing this file: do NOT add another `agentId === "..."` branch,
7984// agent table, or agent-specific hook without explicit approval from the
......@@ -182,11 +187,17 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu
182187 const startupKey =
183188 options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId;
184189 const isDevin = registryAgentId === "devin";
190 // Local installs launch the `copilot` binary directly with no registry id.
191 const isCopilot =
192 registryAgentId === "github-copilot-cli" ||
193 (options.settings.source === "local" &&
194 /^copilot(\.exe|\.cmd)?$/i.test(options.settings.commandPath.split(/[\\/]/).pop() ?? ""));
195 const copilotRouting = isCopilot ? makeCopilotSubagentRouting() : undefined;
185196 const flavor: AcpAdapterV2Flavor = {
186197 driver: ACP_REGISTRY_PROVIDER,
187198 capabilities: AcpProviderCapabilitiesV2,
188199 promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause),
189 // Per-agent exceptions (Mistral Vibe, Devin): see the note above
200 // Per-agent exceptions (Mistral Vibe, Devin, Copilot): see the note above
190201 // registerMistralVibeAcpExtensions before adding any more.
191202 ...(registryAgentId === "mistral-vibe"
192203 ? { registerExtensions: registerMistralVibeAcpExtensions }
......@@ -202,6 +213,14 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu
202213 extractSubagentUpdate: extractDevinSubagentUpdate,
203214 }
204215 : {}),
216 ...(copilotRouting === undefined
217 ? {}
218 : {
219 clientCapabilitiesMeta: copilotClientCapabilitiesMeta,
220 registerExtensions: copilotRouting.registerExtensions,
221 normalizeSessionUpdate: copilotRouting.normalizeSessionUpdate,
222 extractSubagentUpdate: extractCopilotSubagentUpdate,
223 }),
205224 makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options, catalog),
206225 ...(runtimeCoordinator === undefined
207226 ? {}
packages/provider-acp-registry/src/server/copilotAcp.ts created+175
......@@ -0,0 +1,175 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import type * as AcpSchema from "effect-acp/compat";
4import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
5import type {
6 AcpAdapterV2ExtensionContext,
7 AcpAdapterV2SubagentUpdate,
8} from "@t3tools/provider-acp/server/adapter";
9
10// Copilot CLI streams subagent text as plain `agent_message_chunk`s on the
11// root session. Only its opt-in raw event feed says who wrote a chunk: each
12// chunk is preceded by an `assistant.*_delta` carrying the spawning `task`
13// tool call id. Both arrive inline on the same reader, in wire order, so the
14// delta just before a chunk is that chunk's author.
15
16const COPILOT_META_KEY = "github.com/copilot";
17const COPILOT_SESSION_EVENT = "github.com/copilot/sessionEvent";
18
19/** Subscribes to the raw events subagent routing needs, and nothing else. */
20export const copilotClientCapabilitiesMeta = {
21 [COPILOT_META_KEY]: {
22 events: [
23 "subagent.started",
24 "subagent.completed",
25 "subagent.failed",
26 "assistant.message_delta",
27 "assistant.reasoning_delta",
28 ],
29 },
30};
31
32const CopilotSessionEvent = Schema.Struct({
33 sessionId: Schema.String,
34 type: Schema.String,
35 agentId: Schema.optionalKey(Schema.String),
36 data: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
37});
38
39function record(value: unknown): Record<string, unknown> | undefined {
40 return typeof value === "object" && value !== null && !Array.isArray(value)
41 ? (value as Record<string, unknown>)
42 : undefined;
43}
44
45const childSessionId = (taskToolCallId: string) => `copilot-task:${taskToolCallId}`;
46
47interface CopilotSessionAttribution {
48 /** The last raw delta, consumed by the chunk that follows it. */
49 delta: { readonly kind: string; readonly text: string; readonly task?: string } | undefined;
50 /** Running subagent id to the `task` tool call that spawned it. */
51 readonly taskByAgentId: Map<string, string>;
52}
53
54/** Per adapter instance; entries are keyed by root session and drop once idle. */
55export function makeCopilotSubagentRouting() {
56 const sessions = new Map<string, CopilotSessionAttribution>();
57 const session = (sessionId: string) => {
58 let current = sessions.get(sessionId);
59 if (current === undefined) {
60 current = { delta: undefined, taskByAgentId: new Map() };
61 sessions.set(sessionId, current);
62 }
63 return current;
64 };
65 const release = (sessionId: string, current: CopilotSessionAttribution) => {
66 if (current.delta === undefined && current.taskByAgentId.size === 0) {
67 sessions.delete(sessionId);
68 }
69 };
70
71 const registerExtensions = (context: AcpAdapterV2ExtensionContext) =>
72 context.runtime.handleExtNotification(COPILOT_SESSION_EVENT, CopilotSessionEvent, (event) => {
73 const data = event.data ?? {};
74 const task = typeof data.parentToolCallId === "string" ? data.parentToolCallId : undefined;
75 switch (event.type) {
76 case "assistant.message_delta":
77 case "assistant.reasoning_delta": {
78 if (typeof data.deltaContent !== "string") return Effect.void;
79 session(event.sessionId).delta = {
80 kind: event.type,
81 text: data.deltaContent,
82 ...(task === undefined ? {} : { task }),
83 };
84 return Effect.void;
85 }
86 case "subagent.started": {
87 if (event.agentId !== undefined && typeof data.toolCallId === "string") {
88 session(event.sessionId).taskByAgentId.set(event.agentId, data.toolCallId);
89 }
90 return Effect.void;
91 }
92 case "subagent.completed":
93 case "subagent.failed": {
94 const current = sessions.get(event.sessionId);
95 if (current !== undefined && event.agentId !== undefined) {
96 current.taskByAgentId.delete(event.agentId);
97 release(event.sessionId, current);
98 }
99 if (typeof data.toolCallId !== "string") return Effect.void;
100 // Background tasks end only here; their tool call settles at launch.
101 return context.finishSubagent({
102 sessionId: event.sessionId,
103 childSessionId: childSessionId(data.toolCallId),
104 status: event.type === "subagent.failed" ? "failed" : "completed",
105 result: typeof data.error === "string" ? data.error : null,
106 });
107 }
108 default:
109 return Effect.void;
110 }
111 });
112
113 /** Moves a subagent's chunks and tool calls onto its own child session. */
114 const normalizeSessionUpdate = (
115 notification: AcpSchema.SessionNotification,
116 ): AcpSchema.SessionNotification => {
117 const update = notification.update;
118 const current = sessions.get(notification.sessionId);
119 if (current === undefined) return notification;
120 let task: string | undefined;
121 if (
122 update.sessionUpdate === "agent_message_chunk" ||
123 update.sessionUpdate === "agent_thought_chunk"
124 ) {
125 const delta = current.delta;
126 current.delta = undefined;
127 release(notification.sessionId, current);
128 const kind =
129 update.sessionUpdate === "agent_message_chunk"
130 ? "assistant.message_delta"
131 : "assistant.reasoning_delta";
132 if (
133 delta?.kind === kind &&
134 update.content.type === "text" &&
135 update.content.text === delta.text
136 ) {
137 task = delta.task;
138 }
139 } else if (
140 update.sessionUpdate === "tool_call" ||
141 update.sessionUpdate === "tool_call_update"
142 ) {
143 const agentId = record(record(update._meta)?.[COPILOT_META_KEY])?.agentId;
144 task = typeof agentId === "string" ? current.taskByAgentId.get(agentId) : undefined;
145 }
146 return task === undefined ? notification : { ...notification, sessionId: childSessionId(task) };
147 };
148
149 return { registerExtensions, normalizeSessionUpdate };
150}
151
152/** Copilot's `task` tool launches a subagent; its id names the child session. */
153export function extractCopilotSubagentUpdate(
154 tool: AcpToolCallState,
155): AcpAdapterV2SubagentUpdate | undefined {
156 const input = record(tool.data.rawInput);
157 if (typeof input?.agent_type !== "string" || typeof input.prompt !== "string") return undefined;
158 const output = record(tool.data.rawOutput)?.content;
159 const background = input.mode === "background";
160 const status =
161 tool.status === "failed"
162 ? "failed"
163 : tool.status === "completed" && !background
164 ? "completed"
165 : "running";
166 return {
167 nativeTaskId: tool.toolCallId,
168 childSessionId: childSessionId(tool.toolCallId),
169 prompt: input.prompt,
170 title: typeof input.description === "string" ? input.description : null,
171 model: typeof input.model === "string" ? input.model : null,
172 status,
173 result: status !== "running" && typeof output === "string" ? output : null,
174 };
175}
packages/provider-acp-registry/src/testing.ts+1
......@@ -16,6 +16,7 @@ export {
1616 normalizeDevinSessionUpdate,
1717 normalizeDevinToolCall,
1818} from "./server/devinAcp.ts";
19export { extractCopilotSubagentUpdate, makeCopilotSubagentRouting } from "./server/copilotAcp.ts";
1920export {
2021 acpRegistryProbeFailure,
2122 acpRegistryProbeResult,
packages/provider-acp/src/server/adapter.ts+3-1
......@@ -218,7 +218,9 @@ export interface AcpAdapterV2Flavor {
218218 readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure;
219219 readonly driver: ProviderDriverKind;
220220 readonly capabilities: OrchestrationV2ProviderCapabilities;
221 readonly clientCapabilitiesMeta?: Record<string, boolean>;
221 readonly clientCapabilitiesMeta?: NonNullable<
222 EffectAcpSchema.InitializeRequest["clientCapabilities"]
223 >["_meta"];
222224 readonly normalizeSessionUpdate?: (
223225 notification: EffectAcpSchema.SessionNotification,
224226 ) => EffectAcpSchema.SessionNotification;