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}