| author | |
| committer | |
| log | 818e2be75e1a68bbcac122eac1b6fb815ccf4b73 |
| tree | 2ca3b968e2fc761d81d21c600a80bf9e4bd852ca |
| parent | 9afde1c6dbe00d2b87829732c88789c10f3429c0 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | extractCopilotSubagentUpdate, | ||
| 3 | makeCopilotSubagentRouting, | ||
| 2 | normalizeDevinSessionUpdate, | 4 | normalizeDevinSessionUpdate, |
| 3 | normalizeDevinToolCall, | 5 | normalizeDevinToolCall, |
| 4 | extractDevinSubagentUpdate, | 6 | extractDevinSubagentUpdate, |
| ... | @@ -1464,6 +1466,206 @@ describe("AcpAdapterV2", () => { | ... | @@ -1464,6 +1466,206 @@ describe("AcpAdapterV2", () => { |
| 1464 | }).pipe(Effect.provide(layerTest), Effect.scoped), | 1466 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 1465 | ); | 1467 | ); |
| 1466 | 1468 | ||
| 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 | |||
| 1467 | it.effect("projects ACP v2 fidelity updates into first-class orchestration items", () => | 1669 | it.effect("projects ACP v2 fidelity updates into first-class orchestration items", () => |
| 1468 | Effect.gen(function* () { | 1670 | Effect.gen(function* () { |
| 1469 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 1671 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
packages/provider-acp-registry/src/server/adapter.ts+21-2| ... | @@ -3,6 +3,11 @@ import { | ... | @@ -3,6 +3,11 @@ import { |
| 3 | normalizeDevinToolCall, | 3 | normalizeDevinToolCall, |
| 4 | extractDevinSubagentUpdate, | 4 | extractDevinSubagentUpdate, |
| 5 | } from "./devinAcp.ts"; | 5 | } from "./devinAcp.ts"; |
| 6 | import { | ||
| 7 | copilotClientCapabilitiesMeta, | ||
| 8 | extractCopilotSubagentUpdate, | ||
| 9 | makeCopilotSubagentRouting, | ||
| 10 | } from "./copilotAcp.ts"; | ||
| 6 | import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; | 11 | import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; |
| 7 | import { AcpRegistrySettings } from "../settings.ts"; | 12 | import { AcpRegistrySettings } from "../settings.ts"; |
| 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| ... | @@ -73,7 +78,7 @@ export interface AcpRegistryAdapterV2Options { | ... | @@ -73,7 +78,7 @@ export interface AcpRegistryAdapterV2Options { |
| 73 | // Agent-specific behavior does not belong here: an agent that needs it gets a | 78 | // Agent-specific behavior does not belong here: an agent that needs it gets a |
| 74 | // dedicated driver (as Grok and Antigravity have). The few exceptions below | 79 | // dedicated driver (as Grok and Antigravity have). The few exceptions below |
| 75 | // predate that rule and are small presentation hooks, not permission or tool | 80 | // 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. |
| 77 | // | 82 | // |
| 78 | // Agents changing this file: do NOT add another `agentId === "..."` branch, | 83 | // Agents changing this file: do NOT add another `agentId === "..."` branch, |
| 79 | // agent table, or agent-specific hook without explicit approval from the | 84 | // agent table, or agent-specific hook without explicit approval from the |
| ... | @@ -182,11 +187,17 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu | ... | @@ -182,11 +187,17 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu |
| 182 | const startupKey = | 187 | const startupKey = |
| 183 | options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; | 188 | options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; |
| 184 | const isDevin = registryAgentId === "devin"; | 189 | 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; | ||
| 185 | const flavor: AcpAdapterV2Flavor = { | 196 | const flavor: AcpAdapterV2Flavor = { |
| 186 | driver: ACP_REGISTRY_PROVIDER, | 197 | driver: ACP_REGISTRY_PROVIDER, |
| 187 | capabilities: AcpProviderCapabilitiesV2, | 198 | capabilities: AcpProviderCapabilitiesV2, |
| 188 | promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), | 199 | 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 |
| 190 | // registerMistralVibeAcpExtensions before adding any more. | 201 | // registerMistralVibeAcpExtensions before adding any more. |
| 191 | ...(registryAgentId === "mistral-vibe" | 202 | ...(registryAgentId === "mistral-vibe" |
| 192 | ? { registerExtensions: registerMistralVibeAcpExtensions } | 203 | ? { registerExtensions: registerMistralVibeAcpExtensions } |
| ... | @@ -202,6 +213,14 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu | ... | @@ -202,6 +213,14 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu |
| 202 | extractSubagentUpdate: extractDevinSubagentUpdate, | 213 | extractSubagentUpdate: extractDevinSubagentUpdate, |
| 203 | } | 214 | } |
| 204 | : {}), | 215 | : {}), |
| 216 | ...(copilotRouting === undefined | ||
| 217 | ? {} | ||
| 218 | : { | ||
| 219 | clientCapabilitiesMeta: copilotClientCapabilitiesMeta, | ||
| 220 | registerExtensions: copilotRouting.registerExtensions, | ||
| 221 | normalizeSessionUpdate: copilotRouting.normalizeSessionUpdate, | ||
| 222 | extractSubagentUpdate: extractCopilotSubagentUpdate, | ||
| 223 | }), | ||
| 205 | makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options, catalog), | 224 | makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options, catalog), |
| 206 | ...(runtimeCoordinator === undefined | 225 | ...(runtimeCoordinator === undefined |
| 207 | ? {} | 226 | ? {} |
packages/provider-acp-registry/src/server/copilotAcp.ts created+175| ... | @@ -0,0 +1,175 @@ | ||
| 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 | } | ||
packages/provider-acp-registry/src/testing.ts+1| ... | @@ -16,6 +16,7 @@ export { | ... | @@ -16,6 +16,7 @@ export { |
| 16 | normalizeDevinSessionUpdate, | 16 | normalizeDevinSessionUpdate, |
| 17 | normalizeDevinToolCall, | 17 | normalizeDevinToolCall, |
| 18 | } from "./server/devinAcp.ts"; | 18 | } from "./server/devinAcp.ts"; |
| 19 | export { extractCopilotSubagentUpdate, makeCopilotSubagentRouting } from "./server/copilotAcp.ts"; | ||
| 19 | export { | 20 | export { |
| 20 | acpRegistryProbeFailure, | 21 | acpRegistryProbeFailure, |
| 21 | acpRegistryProbeResult, | 22 | acpRegistryProbeResult, |
packages/provider-acp/src/server/adapter.ts+3-1| ... | @@ -218,7 +218,9 @@ export interface AcpAdapterV2Flavor { | ... | @@ -218,7 +218,9 @@ export interface AcpAdapterV2Flavor { |
| 218 | readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; | 218 | readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; |
| 219 | readonly driver: ProviderDriverKind; | 219 | readonly driver: ProviderDriverKind; |
| 220 | readonly capabilities: OrchestrationV2ProviderCapabilities; | 220 | readonly capabilities: OrchestrationV2ProviderCapabilities; |
| 221 | readonly clientCapabilitiesMeta?: Record<string, boolean>; | 221 | readonly clientCapabilitiesMeta?: NonNullable< |
| 222 | EffectAcpSchema.InitializeRequest["clientCapabilities"] | ||
| 223 | >["_meta"]; | ||
| 222 | readonly normalizeSessionUpdate?: ( | 224 | readonly normalizeSessionUpdate?: ( |
| 223 | notification: EffectAcpSchema.SessionNotification, | 225 | notification: EffectAcpSchema.SessionNotification, |
| 224 | ) => EffectAcpSchema.SessionNotification; | 226 | ) => EffectAcpSchema.SessionNotification; |