| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | import type * as AcpSchema from "effect-acp/compat"; |
| 4 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; |
| 5 | import 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 | |
| 16 | const COPILOT_META_KEY = "github.com/copilot"; |
| 17 | const COPILOT_SESSION_EVENT = "github.com/copilot/sessionEvent"; |
| 18 | |
| 19 | /** Subscribes to the raw events subagent routing needs, and nothing else. */ |
| 20 | export 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 | |
| 32 | const 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 | |
| 39 | function 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 | |
| 45 | const childSessionId = (taskToolCallId: string) => `copilot-task:${taskToolCallId}`; |
| 46 | |
| 47 | interface 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. */ |
| 55 | export 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. */ |
| 153 | export 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 | } |