Files
adolf/extensions/copilot/src/event-bridge.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

623 lines
19 KiB
TypeScript

// Copilot plugin module implements event bridge behavior.
import type { MessageOptions, SessionEvent, SessionEventType } from "@github/copilot-sdk";
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
buildCopilotAssistantUsage,
normalizeCopilotUsage,
type CopilotUsageSnapshot,
} from "./usage-bridge.js";
export type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
export type AssistantUsageSnapshot = CopilotUsageSnapshot;
export interface OnAssistantDeltaPayload {
delta: string;
sessionId?: string;
text: string;
usage?: AssistantUsageSnapshot;
}
export interface SessionLike {
abort(): Promise<void>;
disconnect(): Promise<void>;
id?: string;
off?: (eventType: string, handler: (...args: unknown[]) => void) => void;
on: {
<K extends SessionEventType>(
eventType: K,
handler: (event: Extract<SessionEvent, { type: K }>) => void,
): (() => void) | void;
(eventType: string, handler: (event: SessionEvent) => void): (() => void) | void;
};
rpc?: {
history?: {
cancelBackgroundCompaction?: () => Promise<unknown>;
};
};
sendAndWait(options: MessageOptions, timeout?: number): Promise<SessionEvent | undefined>;
sessionId?: string;
}
export interface EventBridgeOptions {
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
onAgentEvent?: (event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}) => void | Promise<void>;
onNativeSubagentEvent?: (
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
) => void;
onCompactionComplete?: (payload: {
messagesRemoved?: number;
success: boolean;
}) => void | Promise<void>;
onCompactionStart?: () => void | Promise<void>;
getSdkSessionId: () => string | undefined;
isAborted: () => boolean;
}
export interface EventBridgeSnapshot {
readonly assistantTexts: readonly string[];
readonly completedCount: number;
readonly lastAssistantEvent: Extract<SessionEvent, { type: "assistant.message" }> | undefined;
readonly startedCount: number;
readonly streamError: Error | undefined;
readonly toolMetas: ReadonlyArray<{ meta?: string; toolName: string }>;
readonly usage: AssistantUsageSnapshot | undefined;
}
export interface BuildAssistantMessageArgs {
modelRef: { api?: string; id: string; provider: string };
now: () => number;
}
export interface EventBridgeController {
recordSendResult(result: SessionEvent | undefined): boolean;
awaitCompactionChain(): Promise<void>;
awaitCompactionCompletion(): Promise<void>;
awaitSessionIdle(): Promise<void>;
settleCompactionWait(): void;
awaitDeltaChain(): Promise<void>;
awaitAgentEventChain(): Promise<void>;
hasObservedCompaction(): boolean;
hasObservedSessionIdle(): boolean;
isCompacting(): boolean;
snapshot(): EventBridgeSnapshot;
buildAssistantMessage(args: BuildAssistantMessageArgs): AssistantMessage | undefined;
finalizeAssistantTexts(): string[];
detach(): void;
}
type MessageAccumulator = { messageId: string; text: string };
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
export function attachEventBridge(
session: SessionLike,
options: EventBridgeOptions,
): EventBridgeController {
const messageOrder: string[] = [];
const messagesById = new Map<string, MessageAccumulator>();
const reasoningOrder: string[] = [];
const reasoningById = new Map<string, string>();
let lastAssistantEvent: Extract<SessionEvent, { type: "assistant.message" }> | undefined;
let usage: AssistantUsageSnapshot | undefined;
let streamError: Error | undefined;
const toolMetas: Array<{ meta?: string; toolName: string }> = [];
const toolNamesByCallId = new Map<string, string>();
let startedCount = 0;
let completedCount = 0;
let activeCompactionCount = 0;
let observedCompaction = false;
let deltaQueue = Promise.resolve();
let deltaChain = Promise.resolve();
let agentEventChain = Promise.resolve();
let compactionChain = Promise.resolve();
let compactionIdle = Promise.resolve();
let resolveCompactionIdle: (() => void) | undefined;
let observedSessionIdle = false;
let resolveSessionIdle: (() => void) | undefined;
const sessionIdle = new Promise<void>((resolve) => {
resolveSessionIdle = resolve;
});
let firstDeltaError: unknown;
let detached = false;
const unsubscribeFns: Array<() => void> = [];
registerListener(session, unsubscribeFns, "assistant.message_delta", (event) => {
if (!isRootSessionEvent(event)) {
return;
}
const messageId = readString(event.data.messageId) ?? "assistant-message";
const delta = event.data.deltaContent;
if (!delta) {
return;
}
const entry = ensureMessageAccumulator(messagesById, messageOrder, messageId);
entry.text += delta;
const onAssistantDelta = options.onAssistantDelta;
if (!onAssistantDelta) {
return;
}
const payload: OnAssistantDeltaPayload = {
delta,
sessionId: options.getSdkSessionId(),
text: entry.text,
usage,
};
deltaQueue = deltaQueue
.then(
() => onAssistantDelta(payload),
() => onAssistantDelta(payload),
)
.catch((error: unknown) => {
firstDeltaError ??= error;
});
deltaChain = deltaQueue.then(() => {
if (firstDeltaError !== undefined) {
throw toLintErrorObject(firstDeltaError, "Non-Error thrown");
}
});
void deltaChain.catch(() => undefined);
});
registerListener(session, unsubscribeFns, "assistant.reasoning_delta", (event) => {
if (!isRootSessionEvent(event)) {
return;
}
const reasoningId = readString(event.data.reasoningId) ?? "assistant-reasoning";
const delta = event.data.deltaContent;
if (!delta) {
return;
}
if (!reasoningById.has(reasoningId)) {
reasoningById.set(reasoningId, "");
reasoningOrder.push(reasoningId);
}
reasoningById.set(reasoningId, `${reasoningById.get(reasoningId) ?? ""}${delta}`);
});
registerListener(session, unsubscribeFns, "assistant.message", (event) => {
if (!isRootSessionEvent(event)) {
return;
}
lastAssistantEvent = event;
const entry = ensureMessageAccumulator(messagesById, messageOrder, event.data.messageId);
if (typeof event.data.content === "string" && event.data.content.length >= entry.text.length) {
entry.text = event.data.content;
}
});
registerListener(session, unsubscribeFns, "assistant.usage", (event) => {
if (!isRootSessionEvent(event)) {
return;
}
usage = normalizeCopilotUsage(event.data);
});
registerListener(session, unsubscribeFns, "tool.execution_start", (event) => {
if (isRootSessionEvent(event)) {
startedCount += 1;
}
toolNamesByCallId.set(event.data.toolCallId, event.data.toolName);
toolMetas.push({ toolName: event.data.toolName });
});
registerListener(session, unsubscribeFns, "tool.execution_complete", (event) => {
if (isRootSessionEvent(event)) {
completedCount += 1;
}
const toolName = toolNamesByCallId.get(event.data.toolCallId);
const meta = event.data.success
? (event.data.result?.detailedContent ?? event.data.result?.content)
: event.data.error?.message;
if (toolName) {
toolMetas.push({ meta, toolName });
}
});
registerListener(session, unsubscribeFns, "session.plan_changed", (event) => {
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: event.data.operation,
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "exit_plan_mode.requested", (event) => {
const steps = splitPlanText(event.data.planContent);
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
...(event.data.summary ? { explanation: event.data.summary } : {}),
...(steps.length > 0 ? { steps } : {}),
...(event.data.actions.length > 0 ? { actions: event.data.actions } : {}),
...(event.data.requestId ? { requestId: event.data.requestId } : {}),
...(event.data.recommendedAction
? { recommendedAction: event.data.recommendedAction }
: {}),
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "exit_plan_mode.completed", (event) => {
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan decision",
source: "copilot-sdk",
requestId: event.data.requestId,
...(event.data.approved !== undefined ? { approved: event.data.approved } : {}),
...(event.data.autoApproveEdits !== undefined
? { autoApproveEdits: event.data.autoApproveEdits }
: {}),
...(event.data.feedback ? { feedback: event.data.feedback } : {}),
...(event.data.selectedAction ? { selectedAction: event.data.selectedAction } : {}),
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.completed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.failed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
if (!isRootCompactionEvent(event)) {
return;
}
observedCompaction = true;
if (activeCompactionCount === 0) {
compactionIdle = new Promise<void>((resolve) => {
resolveCompactionIdle = resolve;
});
}
activeCompactionCount += 1;
enqueueCompactionCallback(options.onCompactionStart);
});
registerListener(session, unsubscribeFns, "session.compaction_complete", (event) => {
if (!isRootCompactionEvent(event)) {
return;
}
activeCompactionCount = Math.max(0, activeCompactionCount - 1);
enqueueCompactionCallback(() =>
options.onCompactionComplete?.({
...(event.data.messagesRemoved !== undefined
? { messagesRemoved: event.data.messagesRemoved }
: {}),
success: event.data.success,
}),
);
if (activeCompactionCount === 0) {
resolveCompactionIdle?.();
resolveCompactionIdle = undefined;
}
});
registerListener(session, unsubscribeFns, "session.idle", (event) => {
if (!isRootCompactionEvent(event)) {
return;
}
observedSessionIdle = true;
resolveSessionIdle?.();
resolveSessionIdle = undefined;
});
registerListener(session, unsubscribeFns, "session.error", (event) => {
if (!options.isAborted()) {
streamError = createPromptError(
event.data.errorCode ?? event.data.errorType,
event.data.message,
);
}
});
registerListener(session, unsubscribeFns, "abort", (event) => {
if (!options.isAborted()) {
streamError = createPromptError(
"session_aborted",
`[copilot-attempt] session aborted: ${event.data.reason}`,
);
}
});
return {
recordSendResult(result) {
if (!isAssistantMessageEvent(result)) {
return false;
}
lastAssistantEvent = result;
return true;
},
awaitCompactionChain() {
return compactionChain;
},
async awaitCompactionCompletion() {
await awaitStableCompaction();
},
awaitSessionIdle() {
return observedSessionIdle ? Promise.resolve() : sessionIdle;
},
settleCompactionWait() {
activeCompactionCount = 0;
resolveCompactionIdle?.();
resolveCompactionIdle = undefined;
},
awaitDeltaChain() {
return deltaChain;
},
awaitAgentEventChain() {
return agentEventChain;
},
hasObservedCompaction() {
return observedCompaction;
},
hasObservedSessionIdle() {
return observedSessionIdle;
},
isCompacting() {
return activeCompactionCount > 0;
},
snapshot() {
return {
assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent),
completedCount,
lastAssistantEvent,
startedCount,
streamError,
toolMetas: toolMetas.map((toolMeta) => Object.assign({}, toolMeta)),
usage: usage ? { ...usage } : undefined,
};
},
buildAssistantMessage(args) {
return buildAssistantMessage({
event: lastAssistantEvent,
modelRef: args.modelRef,
now: args.now,
reasoningById,
reasoningOrder,
usage,
assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent),
});
},
finalizeAssistantTexts() {
return finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent);
},
detach() {
if (detached) {
return;
}
detached = true;
for (const unsubscribe of [...unsubscribeFns].toReversed()) {
try {
unsubscribe();
} catch {
// best-effort cleanup only
}
}
unsubscribeFns.length = 0;
},
};
function enqueueCompactionCallback(callback: (() => void | Promise<void>) | undefined): void {
if (!callback) {
return;
}
const queued = compactionChain.then(callback, callback);
compactionChain = queued.catch(() => undefined);
}
function enqueueAgentEvent(event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}): void {
const callback = options.onAgentEvent;
if (!callback) {
return;
}
const invoke = () => callback(event);
agentEventChain = agentEventChain.then(invoke, invoke).catch(() => undefined);
}
function forwardNativeSubagentEvent(
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
): void {
try {
options.onNativeSubagentEvent?.(event);
} catch {
// Native task mirroring must not corrupt the Copilot turn.
}
}
async function awaitStableCompaction(): Promise<void> {
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
if (idle) {
await idle;
}
const callbacks = compactionChain;
await callbacks;
// Compaction events can arrive while an earlier hook callback settles.
// Recheck both queues before teardown so the root observer stays attached.
if (activeCompactionCount > 0 || compactionChain !== callbacks) {
await awaitStableCompaction();
}
}
}
function buildAssistantMessage(params: {
assistantTexts: string[];
event?: Extract<SessionEvent, { type: "assistant.message" }>;
modelRef: { api?: string; id: string; provider: string };
now: () => number;
reasoningById: Map<string, string>;
reasoningOrder: string[];
usage?: AssistantUsageSnapshot;
}): AssistantMessage | undefined {
const event = params.event;
const text = event
? event.data.content || params.assistantTexts[params.assistantTexts.length - 1] || ""
: "";
const reasoningText =
event?.data.reasoningText ?? joinReasoning(params.reasoningOrder, params.reasoningById);
const toolRequests = event?.data.toolRequests ?? [];
if (!text && !reasoningText && toolRequests.length === 0) {
return undefined;
}
const content: AssistantMessage["content"] = [];
if (reasoningText) {
content.push({ thinking: reasoningText, type: "thinking" });
}
if (text) {
content.push({ text, type: "text" });
}
for (const request of toolRequests) {
content.push({
arguments: request.arguments ?? {},
id: request.toolCallId,
name: request.name,
type: "toolCall",
});
}
return {
api: params.modelRef.api ?? "openai-responses",
content,
model: event?.data.model ?? params.modelRef.id,
provider: params.modelRef.provider,
role: "assistant",
stopReason: toolRequests.length > 0 ? "toolUse" : "stop",
timestamp: params.now(),
usage: buildCopilotAssistantUsage({
fallbackOutputTokens: event?.data.outputTokens,
usage: params.usage,
}),
};
}
function createPromptError(code: string, message: string, cause?: unknown): PromptErrorWithCode {
const error = new Error(message) as PromptErrorWithCode;
error.code = code;
if (cause !== undefined) {
error.cause = cause;
}
return error;
}
function ensureMessageAccumulator(
messagesById: Map<string, MessageAccumulator>,
messageOrder: string[],
messageId: string,
): MessageAccumulator {
let entry = messagesById.get(messageId);
if (!entry) {
entry = { messageId, text: "" };
messagesById.set(messageId, entry);
messageOrder.push(messageId);
}
return entry;
}
function finalizeAssistantTexts(
messageOrder: string[],
messagesById: Map<string, MessageAccumulator>,
event?: Extract<SessionEvent, { type: "assistant.message" }>,
): string[] {
const texts = messageOrder
.map((messageId) => messagesById.get(messageId)?.text ?? "")
.filter((text) => text.length > 0);
if (texts.length > 0) {
return texts;
}
if (event?.data.content) {
return [event.data.content];
}
return [];
}
function isAssistantMessageEvent(
event: SessionEvent | undefined,
): event is Extract<SessionEvent, { type: "assistant.message" }> {
return event?.type === "assistant.message";
}
function isRootSessionEvent(event: { agentId?: string }): boolean {
return event.agentId === undefined;
}
function isRootCompactionEvent(event: { agentId?: string }): boolean {
// SDK session events include subagent compaction; only root compaction
// affects the pooled root session's cleanup and reuse lifecycle.
return isRootSessionEvent(event);
}
function joinReasoning(order: string[], reasoningById: Map<string, string>): string {
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
}
function splitPlanText(text: string | undefined): string[] {
return (text ?? "")
.split(/\r?\n/)
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
.filter((line) => line.length > 0);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function registerListener<K extends SessionEventType>(
session: SessionLike,
unsubscribeFns: Array<() => void>,
eventType: K,
handler: (event: Extract<SessionEvent, { type: K }>) => void,
): void {
const maybeUnsubscribe = session.on(eventType, handler);
if (typeof maybeUnsubscribe === "function") {
unsubscribeFns.push(maybeUnsubscribe);
return;
}
unsubscribeFns.push(() => {
session.off?.(eventType, handler as (...args: unknown[]) => void);
});
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}