refactor(copilot): remove dormant bridge scaffolding

This commit is contained in:
Vincent Koc
2026-06-18 21:32:46 +08:00
parent 7cca3d4618
commit 44500daaa0
8 changed files with 5 additions and 966 deletions

View File

@@ -179,8 +179,7 @@ The harness reads its config from per-attempt input
registered with `overridesBuiltInTool: true` and
`skipPermission: true` so 100% of tool calls flow through OpenClaw's
wrapped `execute()`. See [Permissions and ask_user](#permissions-and-ask_user).
- `enableSessionTelemetry` — opt-in OpenTelemetry routing via
`telemetry-bridge.ts`.
- `enableSessionTelemetry` — optional SDK session telemetry flag.
Nothing in the rest of OpenClaw needs to know about these fields. Other
plugins, channels, and core code only see the standard
@@ -267,9 +266,7 @@ real Copilot CLI or touch the host fs.
decisions from the initial prompt rather than asking clarifying
questions mid-turn. A follow-up will port the codex pattern at
`extensions/codex/src/app-server/user-input-bridge.ts` to route SDK
`UserInputRequest`s through the OpenClaw channel/TUI prompt path; the
dormant scaffolding in `extensions/copilot/src/user-input-bridge.ts`
is the surface that follow-up will wire.
`UserInputRequest`s through the OpenClaw channel/TUI prompt path.
## Permissions and ask_user

View File

@@ -983,8 +983,7 @@ describe("runCopilotAttempt", () => {
// Per the SDK contract (types.d.ts: `When provided, enables the
// ask_user tool allowing the agent to ask questions`), omitting the
// handler hides ask_user from the model entirely. The MVP keeps it
// hidden; a follow-up will port the codex user-input-bridge to wire
// ask_user to the OpenClaw channel/TUI path.
// hidden until a real channel/TUI prompt bridge exists.
expect("onUserInputRequest" in cfg).toBe(false);
});

View File

@@ -763,14 +763,8 @@ function createSessionConfig(
onPermissionRequest: createPermissionBridge(permissionPolicy),
// `onUserInputRequest` is intentionally NOT registered: per the SDK
// contract, omitting the handler hides the `ask_user` tool from the
// model entirely. This is the MVP posture — interactive ask_user
// requires routing the request to the OpenClaw channel/TUI prompt
// path (mirroring extensions/codex/src/app-server/user-input-bridge.ts),
// which is tracked as a follow-up. With the handler absent, agents
// running under this harness must make best-judgment decisions from
// the initial prompt rather than asking clarifying questions
// mid-turn. See user-input-bridge.ts for the dormant policy
// scaffolding the follow-up will reuse.
// model entirely. Interactive ask_user will need a real channel/TUI
// prompt bridge before this runtime can expose the handler.
// SessionHooks: only set when the host actually supplied handlers.
// createHooksBridge returns undefined for an empty config so we
// never install an empty hooks subsystem. See hooks-bridge.ts for
@@ -779,8 +773,6 @@ function createSessionConfig(
// Session-level telemetry opt-out: only propagate when the host
// explicitly set a boolean. undefined means "use SDK default"
// (enabled for GitHub auth; disabled when a BYOK provider is set).
// Client-level OTel config is plumbed via runtime.ts /
// telemetry-bridge.ts.
...(typeof params.enableSessionTelemetry === "boolean"
? { enableSessionTelemetry: params.enableSessionTelemetry }
: {}),

View File

@@ -1,253 +0,0 @@
// Copilot tests cover telemetry bridge plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
createTelemetryConfig,
createTraceContextProvider,
type CopilotTraceContextErrorInfo,
} from "./telemetry-bridge.js";
describe("createTelemetryConfig", () => {
it("returns undefined for undefined input", () => {
expect(createTelemetryConfig()).toBeUndefined();
});
it("returns undefined when every field is undefined", () => {
expect(createTelemetryConfig({})).toBeUndefined();
expect(
createTelemetryConfig({
otlpEndpoint: undefined,
filePath: undefined,
}),
).toBeUndefined();
});
it("includes only the fields that were explicitly set", () => {
expect(createTelemetryConfig({ otlpEndpoint: "https://otel.example/v1/traces" })).toEqual({
otlpEndpoint: "https://otel.example/v1/traces",
});
expect(createTelemetryConfig({ sourceName: "openclaw" })).toEqual({
sourceName: "openclaw",
});
});
it("round-trips a fully populated config", () => {
const result = createTelemetryConfig({
otlpEndpoint: "https://otel.example/v1/traces",
filePath: "/tmp/openclaw-traces.jsonl",
exporterType: "otlp-http",
sourceName: "openclaw",
captureContent: true,
});
expect(result).toEqual({
otlpEndpoint: "https://otel.example/v1/traces",
filePath: "/tmp/openclaw-traces.jsonl",
exporterType: "otlp-http",
sourceName: "openclaw",
captureContent: true,
});
});
it("preserves captureContent: false (explicit disable, not undefined)", () => {
expect(createTelemetryConfig({ captureContent: false })).toEqual({
captureContent: false,
});
});
it("preserves empty-string values (caller chose to set them)", () => {
expect(createTelemetryConfig({ otlpEndpoint: "" })).toEqual({ otlpEndpoint: "" });
});
});
describe("createTraceContextProvider", () => {
it("returns an empty context when no sources are configured", async () => {
const provider = createTraceContextProvider();
await expect(provider()).resolves.toEqual({});
});
it("prefers getTraceContext over the convenience sources", async () => {
const getTraceContext = vi.fn().mockResolvedValue({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
tracestate: "vendor=value",
});
const getTraceparent = vi.fn().mockResolvedValue("00-ffff-ffff-01");
const provider = createTraceContextProvider({ getTraceContext, getTraceparent });
const ctx = await provider();
expect(ctx).toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
tracestate: "vendor=value",
});
expect(getTraceparent).not.toHaveBeenCalled();
});
it("falls back to getTraceparent when getTraceContext returns undefined", async () => {
const getTraceContext = vi.fn().mockResolvedValue(undefined);
const getTraceparent = vi
.fn()
.mockResolvedValue("00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01");
const provider = createTraceContextProvider({ getTraceContext, getTraceparent });
await expect(provider()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
expect(getTraceContext).toHaveBeenCalledTimes(1);
expect(getTraceparent).toHaveBeenCalledTimes(1);
});
it("includes tracestate when both convenience sources return non-empty values", async () => {
const provider = createTraceContextProvider({
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
getTracestate: () => "vendor=value",
});
await expect(provider()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
tracestate: "vendor=value",
});
});
it("omits empty/undefined tracestate even when traceparent is present", async () => {
const providerUndef = createTraceContextProvider({
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
getTracestate: () => undefined,
});
await expect(providerUndef()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
const providerEmpty = createTraceContextProvider({
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
getTracestate: () => "",
});
await expect(providerEmpty()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
});
it("does not propagate tracestate without traceparent (W3C requirement)", async () => {
const getTracestate = vi.fn().mockResolvedValue("vendor=value");
const provider = createTraceContextProvider({
getTraceparent: () => undefined,
getTracestate,
});
await expect(provider()).resolves.toEqual({});
expect(getTracestate).not.toHaveBeenCalled();
});
it("re-reads sources on every invocation (so caching the provider is safe)", async () => {
let parent = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01";
const provider = createTraceContextProvider({ getTraceparent: () => parent });
await expect(provider()).resolves.toEqual({ traceparent: parent });
parent = "00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01";
await expect(provider()).resolves.toEqual({ traceparent: parent });
});
it("getTraceContext failure → empty context + notifier called with the original error", async () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceContext: () => {
throw new Error("ctx-boom");
},
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
onError,
});
await expect(provider()).resolves.toEqual({});
expect(onError).toHaveBeenCalledTimes(1);
const info = onError.mock.calls[0]?.[0] as CopilotTraceContextErrorInfo;
expect(info.part).toBe("traceContext");
expect(info.error.message).toBe("ctx-boom");
});
it("getTraceparent failure → empty context + notifier called", async () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceparent: async () => {
throw new Error("parent-boom");
},
getTracestate: () => "vendor=value",
onError,
});
await expect(provider()).resolves.toEqual({});
expect(onError).toHaveBeenCalledTimes(1);
expect((onError.mock.calls[0][0] as CopilotTraceContextErrorInfo).part).toBe("traceparent");
});
it("getTracestate failure → partial success (traceparent kept) + notifier called", async () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
getTracestate: () => {
throw new Error("state-boom");
},
onError,
});
await expect(provider()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
expect(onError).toHaveBeenCalledTimes(1);
expect((onError.mock.calls[0][0] as CopilotTraceContextErrorInfo).part).toBe("tracestate");
});
it("default notifier uses console.warn", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const provider = createTraceContextProvider({
getTraceparent: () => {
throw new Error("default-warn-path");
},
});
await expect(provider()).resolves.toEqual({});
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("traceparent");
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("default-warn-path");
} finally {
warnSpy.mockRestore();
}
});
it("normalizes non-Error throws into Error before notifying", async () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceparent: () => {
throw toLintErrorObject("string-boom", "Non-Error thrown");
},
onError,
});
await expect(provider()).resolves.toEqual({});
const info = onError.mock.calls[0]?.[0] as CopilotTraceContextErrorInfo;
expect(info.error).toBeInstanceOf(Error);
expect(info.error.message).toBe("string-boom");
});
it("notifier throws are swallowed (provider always resolves)", async () => {
const provider = createTraceContextProvider({
getTraceparent: () => {
throw new Error("boom");
},
onError: () => {
throw new Error("notifier-boom");
},
});
await expect(provider()).resolves.toEqual({});
});
it("treats only-traceContext source returning empty object as a valid context (no fallback)", async () => {
const getTraceparent = vi.fn();
const provider = createTraceContextProvider({
getTraceContext: () => ({}),
getTraceparent,
});
await expect(provider()).resolves.toEqual({});
expect(getTraceparent).not.toHaveBeenCalled();
});
});
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;
}

View File

@@ -1,219 +0,0 @@
// Copilot plugin module implements telemetry bridge behavior.
import type { CopilotClientOptions } from "@github/copilot-sdk";
// Telemetry bridge for the GitHub Copilot agent runtime.
//
// SDK surface:
// - `CopilotClientOptions.telemetry?: TelemetryConfig` — OpenTelemetry
// configuration applied to the spawned CLI process via env vars.
// - `CopilotClientOptions.onGetTraceContext?: TraceContextProvider` —
// async callback returning a W3C `{traceparent?, tracestate?}` that the
// SDK injects into `session.create`, `session.resume`, and
// `session.send` RPCs for distributed trace propagation.
//
// Host-side back-pointers (NOT imported here to keep the package boundary
// clean — the wiring layer injects these via callbacks):
// - `src/infra/diagnostic-trace-context.ts` — `getActiveDiagnosticTraceContext`,
// `formatDiagnosticTraceparent`, `DiagnosticTraceContext`.
// - `src/infra/diagnostic-events.ts` — `formatDiagnosticTraceparentForPropagation`
// for trusted-only propagation.
//
// IMPORTANT — pool reuse caveat:
// `CopilotClientPool` keys on `{agentId, copilotHome, authMode,
// authProfileId, authProfileVersion}`. Client-level telemetry and
// `onGetTraceContext` are NOT part of the pool key. Two callers that
// share a pool key but supply different telemetry options will get the
// first-acquire's options ("first wins"). Mitigation:
// - The trace-context provider returned by `createTraceContextProvider`
// reads the active context **on every invocation**, so even when the
// provider function is cached the propagated `traceparent` reflects
// the current scope at RPC time. Per-call accuracy is preserved.
// - `TelemetryConfig` (OTel env vars) is genuinely first-wins because
// the CLI subprocess is spawned once per pool entry. Wire telemetry
// as a process-wide / per-agent setting, not per-attempt.
type SdkTraceContext = NonNullable<
Awaited<ReturnType<NonNullable<CopilotClientOptions["onGetTraceContext"]>>>
>;
type SdkTraceContextProvider = NonNullable<CopilotClientOptions["onGetTraceContext"]>;
type SdkTelemetryConfig = NonNullable<CopilotClientOptions["telemetry"]>;
export type { SdkTraceContext as CopilotTraceContext };
export type { SdkTelemetryConfig as CopilotTelemetryConfig };
export type CopilotTraceContextSource = () =>
| SdkTraceContext
| undefined
| Promise<SdkTraceContext | undefined>;
export type CopilotTraceparentSource = () => string | undefined | Promise<string | undefined>;
export type CopilotTracestateSource = () => string | undefined | Promise<string | undefined>;
export interface CopilotTraceContextErrorInfo {
readonly part: "traceContext" | "traceparent" | "tracestate";
readonly error: Error;
}
export interface CopilotTraceContextOptions {
/**
* Primary source: a single callback returning the full SDK trace context
* (`{traceparent?, tracestate?}`). Use this when the host has one
* authoritative source of trace context so that traceparent and tracestate
* always reflect the same logical scope.
*/
getTraceContext?: CopilotTraceContextSource;
/**
* Convenience source: returns just the W3C `traceparent` header. Used
* when {@link getTraceContext} is not supplied OR returns undefined.
*/
getTraceparent?: CopilotTraceparentSource;
/**
* Convenience source: returns the W3C `tracestate` header. Only used
* when {@link getTraceContext} is not supplied AND a non-empty
* `traceparent` was obtained via {@link getTraceparent}. (Per W3C,
* `tracestate` is meaningless without an accompanying `traceparent`.)
*/
getTracestate?: CopilotTracestateSource;
/**
* Notifier for errors thrown by any source. Defaults to `console.warn`.
* Notifier failures are themselves swallowed.
*/
onError?: (info: CopilotTraceContextErrorInfo) => void;
}
const EMPTY_TRACE_CONTEXT: SdkTraceContext = Object.freeze({}) as SdkTraceContext;
function toError(error: unknown): Error {
if (error instanceof Error) {
return error;
}
return new Error(String(error));
}
function defaultOnTraceContextError(info: CopilotTraceContextErrorInfo): void {
console.warn(`[copilot:telemetry-bridge] ${info.part} source failed: ${info.error.message}`);
}
function safeNotify(
notifier: (info: CopilotTraceContextErrorInfo) => void,
info: CopilotTraceContextErrorInfo,
): void {
try {
notifier(info);
} catch {
// Notifier failures are swallowed: telemetry is best-effort.
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
/**
* Build a TraceContextProvider suitable for `CopilotClientOptions.onGetTraceContext`.
*
* Resolution order on each invocation:
* 1. If `getTraceContext` is supplied and returns a non-undefined value,
* return it as-is. Errors from this source → return `{}` and notify.
* 2. Otherwise call `getTraceparent` (if supplied). On error → return
* `{}` and notify (no traceparent = no propagation).
* 3. If traceparent is non-empty, call `getTracestate` (if supplied)
* and attach the result. Errors on tracestate are partial-success:
* notify and return `{traceparent}` (do not lose the parent).
* 4. If no source provided OR all return undefined, return `{}` so the
* SDK behaves as if no provider were configured.
*/
export function createTraceContextProvider(
options?: CopilotTraceContextOptions,
): SdkTraceContextProvider {
const onError = options?.onError ?? defaultOnTraceContextError;
const getTraceContext = options?.getTraceContext;
const getTraceparent = options?.getTraceparent;
const getTracestate = options?.getTracestate;
return async () => {
if (getTraceContext) {
try {
const ctx = await getTraceContext();
if (ctx !== undefined) {
return ctx;
}
} catch (error) {
safeNotify(onError, { part: "traceContext", error: toError(error) });
return EMPTY_TRACE_CONTEXT;
}
}
if (!getTraceparent) {
return EMPTY_TRACE_CONTEXT;
}
let traceparent: string | undefined;
try {
traceparent = await getTraceparent();
} catch (error) {
safeNotify(onError, { part: "traceparent", error: toError(error) });
return EMPTY_TRACE_CONTEXT;
}
if (!isNonEmptyString(traceparent)) {
return EMPTY_TRACE_CONTEXT;
}
if (!getTracestate) {
return { traceparent } as SdkTraceContext;
}
let tracestate: string | undefined;
try {
tracestate = await getTracestate();
} catch (error) {
safeNotify(onError, { part: "tracestate", error: toError(error) });
return { traceparent } as SdkTraceContext;
}
return isNonEmptyString(tracestate)
? ({ traceparent, tracestate } as SdkTraceContext)
: ({ traceparent } as SdkTraceContext);
};
}
export interface CopilotTelemetryOptions {
otlpEndpoint?: string;
filePath?: string;
exporterType?: string;
sourceName?: string;
captureContent?: boolean;
}
/**
* Shape a `TelemetryConfig` for `CopilotClientOptions.telemetry`. Returns
* `undefined` when no fields are supplied so callers can spread
* conditionally without producing an empty telemetry object that would
* still partially configure the CLI's OTel env layout.
*
* Any explicitly-set value (including `false` for `captureContent`) is
* preserved — only `undefined` is treated as "no opinion".
*/
export function createTelemetryConfig(
options?: CopilotTelemetryOptions,
): SdkTelemetryConfig | undefined {
if (!options) {
return undefined;
}
const result: SdkTelemetryConfig = {};
if (options.otlpEndpoint !== undefined) {
result.otlpEndpoint = options.otlpEndpoint;
}
if (options.filePath !== undefined) {
result.filePath = options.filePath;
}
if (options.exporterType !== undefined) {
result.exporterType = options.exporterType;
}
if (options.sourceName !== undefined) {
result.sourceName = options.sourceName;
}
if (options.captureContent !== undefined) {
result.captureContent = options.captureContent;
}
return Object.keys(result).length > 0 ? result : undefined;
}

View File

@@ -1,231 +0,0 @@
// Copilot tests cover user input bridge plugin behavior.
import type { SessionConfig } from "@github/copilot-sdk";
import { describe, expect, it, vi } from "vitest";
type UserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type SdkUserInputRequest = Parameters<UserInputHandler>[0];
type SdkUserInputResponse = Awaited<ReturnType<UserInputHandler>>;
import {
composeUserInputPolicies,
createUserInputBridge,
delegatingUserInputPolicy,
denyAllUserInputPolicy,
firstChoicePolicy,
staticAnswerPolicy,
DENY_ALL_ANSWER,
type CopilotUserInputContext,
type CopilotUserInputPolicy,
} from "./user-input-bridge.js";
function makeRequest(overrides: Partial<SdkUserInputRequest> = {}): SdkUserInputRequest {
return {
question: "what is your name?",
...overrides,
};
}
function makeCtx(overrides: Partial<CopilotUserInputContext> = {}): CopilotUserInputContext {
return {
request: makeRequest(),
sessionId: "sess-1",
...overrides,
};
}
describe("denyAllUserInputPolicy", () => {
it("returns the fail-closed DENY_ALL_ANSWER as a freeform answer", async () => {
const result = await denyAllUserInputPolicy(makeCtx());
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
});
describe("firstChoicePolicy", () => {
it("returns the first choice (wasFreeform: false) when choices are present", async () => {
const result = await firstChoicePolicy(
makeCtx({ request: makeRequest({ choices: ["yes", "no"] }) }),
);
expect(result).toEqual({ answer: "yes", wasFreeform: false });
});
it("falls back to DENY_ALL_ANSWER when choices are empty", async () => {
const result = await firstChoicePolicy(makeCtx({ request: makeRequest({ choices: [] }) }));
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("falls back to DENY_ALL_ANSWER when choices are absent", async () => {
const result = await firstChoicePolicy(makeCtx());
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
});
describe("staticAnswerPolicy", () => {
it("returns the configured answer for every request", async () => {
const policy = staticAnswerPolicy({ answer: "Alice" });
for (const question of ["a?", "b?", "c?"]) {
const result = await policy(makeCtx({ request: makeRequest({ question }) }));
expect(result).toEqual({ answer: "Alice", wasFreeform: true });
}
});
it("respects wasFreeform=false override", async () => {
const policy = staticAnswerPolicy({ answer: "yes", wasFreeform: false });
const result = await policy(makeCtx());
expect(result).toEqual({ answer: "yes", wasFreeform: false });
});
});
describe("delegatingUserInputPolicy", () => {
it("forwards the request and returns the host response", async () => {
const onRequest = vi
.fn<CopilotUserInputPolicy>()
.mockResolvedValue({ answer: "Bob", wasFreeform: true } satisfies SdkUserInputResponse);
const policy = delegatingUserInputPolicy({ onRequest });
const ctx = makeCtx({ sessionId: "sess-xyz" });
const result = await policy(ctx);
expect(result).toEqual({ answer: "Bob", wasFreeform: true });
expect(onRequest).toHaveBeenCalledTimes(1);
expect(onRequest).toHaveBeenCalledWith(ctx);
});
it("returns DENY_ALL_ANSWER when host callback returns undefined", async () => {
const onRequest = vi.fn<CopilotUserInputPolicy>().mockResolvedValue(undefined);
const policy = delegatingUserInputPolicy({ onRequest });
const result = await policy(makeCtx());
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("converts thrown errors into a DENY_ALL_ANSWER with the error message appended", async () => {
const policy = delegatingUserInputPolicy({
onRequest: () => {
throw new Error("prompt timeout");
},
});
const result = await policy(makeCtx());
expect(result).toBeDefined();
expect(result!.wasFreeform).toBe(true);
expect(result!.answer).toContain(DENY_ALL_ANSWER);
expect(result!.answer).toContain("prompt timeout");
});
it("falls back to onError policy when onRequest throws", async () => {
const onError = vi
.fn<CopilotUserInputPolicy>()
.mockResolvedValue({ answer: "fallback", wasFreeform: true });
const policy = delegatingUserInputPolicy({
onRequest: () => {
throw new Error("host boom");
},
onError,
});
const result = await policy(makeCtx());
expect(result).toEqual({ answer: "fallback", wasFreeform: true });
expect(onError).toHaveBeenCalledTimes(1);
});
it("falls through to error-message response when onError also throws", async () => {
const policy = delegatingUserInputPolicy({
onRequest: () => {
throw new Error("host boom");
},
onError: () => {
throw new Error("fallback boom");
},
});
const result = await policy(makeCtx());
expect(result).toBeDefined();
expect(result!.answer).toContain("host boom");
});
it("formats non-Error throws via JSON.stringify", async () => {
const policy = delegatingUserInputPolicy({
onRequest: () => {
throw { code: 7, msg: "weird" } as unknown as Error;
},
});
const result = await policy(makeCtx());
expect(result).toBeDefined();
expect(result!.answer).toContain('"code":7');
});
});
describe("composeUserInputPolicies", () => {
it("returns the first non-undefined result and skips subsequent policies", async () => {
const a: CopilotUserInputPolicy = () => undefined;
const b: CopilotUserInputPolicy = () => ({ answer: "from-b", wasFreeform: true });
const c = vi.fn<CopilotUserInputPolicy>(() => ({ answer: "from-c", wasFreeform: true }));
const policy = composeUserInputPolicies(a, b, c);
const result = await policy(makeCtx());
expect(result).toEqual({ answer: "from-b", wasFreeform: true });
expect(c).not.toHaveBeenCalled();
});
it("falls through to DENY_ALL_ANSWER when all policies return undefined", async () => {
const policy = composeUserInputPolicies(
() => undefined,
() => undefined,
);
const result = await policy(makeCtx());
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("short-circuits to error-message response when any policy throws", async () => {
const later = vi.fn<CopilotUserInputPolicy>(() => ({ answer: "later", wasFreeform: true }));
const policy = composeUserInputPolicies(() => {
throw new Error("compose boom");
}, later);
const result = await policy(makeCtx());
expect(result).toBeDefined();
expect(result!.answer).toContain("compose boom");
expect(later).not.toHaveBeenCalled();
});
});
describe("createUserInputBridge", () => {
it("adapts a policy to the SDK UserInputHandler shape", async () => {
const handler = createUserInputBridge(staticAnswerPolicy({ answer: "Alice" }));
const result = await handler(makeRequest(), { sessionId: "sess-1" });
expect(result).toEqual({ answer: "Alice", wasFreeform: true });
});
it("defaults to denyAllUserInputPolicy when no policy is passed", async () => {
const handler = createUserInputBridge();
const result = await handler(makeRequest(), { sessionId: "sess-1" });
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("forwards the SDK sessionId into the policy context", async () => {
const policy = vi.fn<CopilotUserInputPolicy>(() => ({ answer: "x", wasFreeform: true }));
const handler = createUserInputBridge(policy);
await handler(makeRequest({ question: "q?", choices: ["a"] }), { sessionId: "sess-xyz" });
expect(policy).toHaveBeenCalledTimes(1);
expect(policy.mock.calls[0]?.[0]).toEqual({
sessionId: "sess-xyz",
request: { question: "q?", choices: ["a"] },
});
});
it("never throws when policy throws; returns DENY_ALL_ANSWER with the error message", async () => {
const handler = createUserInputBridge(() => {
throw new Error("policy boom");
});
const result = await handler(makeRequest(), { sessionId: "sess-1" });
expect(result.answer).toContain(DENY_ALL_ANSWER);
expect(result.answer).toContain("policy boom");
expect(result.wasFreeform).toBe(true);
});
it("never returns undefined: a policy returning undefined yields fail-closed answer", async () => {
const handler = createUserInputBridge(() => undefined);
const result = await handler(makeRequest(), { sessionId: "sess-1" });
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("preserves wasFreeform=false from a policy that picked from choices", async () => {
const handler = createUserInputBridge(firstChoicePolicy);
const result = await handler(makeRequest({ choices: ["one", "two"], allowFreeform: false }), {
sessionId: "sess-1",
});
expect(result).toEqual({ answer: "one", wasFreeform: false });
});
});

View File

@@ -1,244 +0,0 @@
/**
* User-input bridge for the copilot agent runtime.
*
* STATUS — MVP DORMANT: This module is intentionally NOT registered with
* the SDK in the current harness (see `attempt.ts` / `side-question.ts`).
* The SDK contract is "When `onUserInputRequest` is provided, enables the
* `ask_user` tool allowing the agent to ask questions" (see
* `node_modules/@github/copilot-sdk/dist/types.d.ts` `SessionConfig`);
* by omitting the handler we hide `ask_user` from the model entirely.
* Agents under the MVP must make best-judgment decisions from the
* initial prompt rather than asking clarifying questions mid-turn.
*
* FOLLOW-UP: The scaffolding below stays in tree so the follow-up that
* ports the codex user-input-bridge pattern
* (`extensions/codex/src/app-server/user-input-bridge.ts`) has a stable
* surface to wire — that change will route SDK `UserInputRequest`s
* through `params.onBlockReply` / `onPartialReply` and resolve the
* pending promise from the next inbound channel message, then register
* `createUserInputBridge(delegatingUserInputPolicy(...))` from
* `createSessionConfig`.
*
* BACK-POINTER: The host-side channel/TUI prompt flow lives outside
* this package boundary in `commitments/` and the channel plugins
* (slack/discord/cli/tui). Per proposal §50, this bridge does NOT
* import that flow directly (the package boundary
* `tsconfig.package-boundary.base.json` only allows
* `openclaw/plugin-sdk/*` and `@github/copilot-sdk`). Instead, this
* module:
*
* 1. Defines a small `CopilotUserInputPolicy` contract that the
* core wiring layer implements to forward `UserInputRequest`s to
* the host's channel/TUI prompt path.
* 2. Provides built-in policies for common defaults (deny-all with a
* synthetic answer, auto-first-choice, static-answer).
* 3. Provides a `delegatingUserInputPolicy({ onRequest })` so the
* core wiring layer can plug in a host-side callback that calls
* into `commitments/` and returns the SDK-shaped response.
* 4. Adapts the resulting policy into the SDK's `UserInputHandler`
* shape via `createUserInputBridge(policy)`.
*
* SDK contract note: unlike `PermissionHandler` (which has a
* `no-result` escape hatch), `UserInputHandler` MUST resolve with a
* `UserInputResponse`. The bridge therefore never returns `undefined`
* to the SDK; if a policy returns `undefined` or throws, the default
* fail-closed answer is used so the model sees a real string rather
* than a generic RPC failure.
*
* If the host's prompt contract changes materially, the contract here
* must be revisited in lockstep. The unit tests in
* `user-input-bridge.test.ts` exercise the SDK-shaped response envelope
* so any silent drift in the SDK type is caught at typecheck.
*/
import type { SessionConfig } from "@github/copilot-sdk";
type UserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type SdkUserInputRequest = Parameters<UserInputHandler>[0];
type SdkUserInputResponse = Awaited<ReturnType<UserInputHandler>>;
/** Request shape forwarded to host-implemented user-input policies. */
export interface CopilotUserInputContext {
/** SDK session id that originated the request. */
sessionId: string;
/** Original SDK request payload. */
request: SdkUserInputRequest;
}
/**
* Policy contract. Implementors return an SDK-shaped response (or a
* Promise of one).
*
* Returning `undefined` is treated as "no opinion" and falls through
* to the default fail-closed response (`DENY_ALL_ANSWER`). This keeps
* composition trivial without requiring explicit responses from every
* code path.
*/
export type CopilotUserInputPolicy = (
ctx: CopilotUserInputContext,
) => SdkUserInputResponse | undefined | Promise<SdkUserInputResponse | undefined>;
/**
* Default answer used when no host policy provides one. The string is
* intentionally explicit so the model can detect the missing-prompt
* condition rather than treating it as a real user answer.
*/
export const DENY_ALL_ANSWER =
"[copilot agent runtime: no user-input policy installed; request declined]";
export const denyAllUserInputPolicy: CopilotUserInputPolicy = () => ({
answer: DENY_ALL_ANSWER,
wasFreeform: true,
});
/**
* Auto-pick the first choice if the request offers choices; otherwise
* fall back to `DENY_ALL_ANSWER` as a freeform answer. Useful for
* non-interactive test runs.
*/
export const firstChoicePolicy: CopilotUserInputPolicy = ({ request }) => {
if (request.choices && request.choices.length > 0) {
return { answer: request.choices[0], wasFreeform: false };
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
export interface StaticAnswerPolicyOptions {
/** Answer returned for every request. */
answer: string;
/**
* Whether the answer should be flagged as a freeform response.
* Defaults to `true` (caller did not pick from `choices`).
*/
wasFreeform?: boolean;
}
/** Always return a fixed answer. Useful for deterministic tests. */
export function staticAnswerPolicy(options: StaticAnswerPolicyOptions): CopilotUserInputPolicy {
const wasFreeform = options.wasFreeform ?? true;
return () => ({ answer: options.answer, wasFreeform });
}
export interface DelegatingUserInputPolicyOptions {
/**
* Host-supplied callback. Returning `undefined` falls through to the
* fail-closed default. Throwing falls back to the configured
* `onError` policy if provided; otherwise the throw is converted to
* a `DENY_ALL_ANSWER` response so the SDK never sees an exception
* (which would surface as a generic RPC failure to the model).
*/
onRequest: CopilotUserInputPolicy;
/**
* Optional fallback when `onRequest` throws. If omitted, throws are
* converted to a `DENY_ALL_ANSWER` response with the error message
* appended. If supplied and `onError` also throws, fall through to
* the error-message response.
*/
onError?: CopilotUserInputPolicy;
}
/**
* Wrap a host callback into a policy, catching synchronous throws and
* async rejections so the SDK never sees an exception.
*/
export function delegatingUserInputPolicy(
options: DelegatingUserInputPolicyOptions,
): CopilotUserInputPolicy {
const { onRequest, onError } = options;
return async (ctx) => {
try {
const result = await onRequest(ctx);
if (result !== undefined) {
return result;
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
} catch (error) {
if (onError) {
try {
const fallback = await onError(ctx);
if (fallback !== undefined) {
return fallback;
}
} catch {
// fall through to error-message response
}
}
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
};
}
/**
* Compose policies in order. The first policy to return a non-undefined
* result wins. If all return undefined, a fail-closed `DENY_ALL_ANSWER`
* response is produced. Throws inside any policy short-circuit to the
* error-message response; downstream policies are not consulted after a
* throw.
*/
export function composeUserInputPolicies(
...policies: CopilotUserInputPolicy[]
): CopilotUserInputPolicy {
return async (ctx) => {
for (const policy of policies) {
try {
const result = await policy(ctx);
if (result !== undefined) {
return result;
}
} catch (error) {
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
}
/**
* Adapt a `CopilotUserInputPolicy` to the SDK's `UserInputHandler`
* shape. The returned handler always resolves with a valid
* `UserInputResponse` (never throws, never returns undefined),
* defaulting to `DENY_ALL_ANSWER` when the policy returns undefined or
* throws.
*/
export function createUserInputBridge(
policy: CopilotUserInputPolicy = denyAllUserInputPolicy,
): UserInputHandler {
return async (
request: SdkUserInputRequest,
invocation: { sessionId: string },
): Promise<SdkUserInputResponse> => {
const ctx: CopilotUserInputContext = {
request,
sessionId: invocation.sessionId,
};
try {
const result = await policy(ctx);
if (result !== undefined) {
return result;
}
} catch (error) {
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}

View File

@@ -14,8 +14,6 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
"extensions/canvas/src/host/a2ui-app/bootstrap.js",
"extensions/canvas/src/host/a2ui-app/rolldown.config.mjs",
"extensions/copilot/src/doctor-probes.ts",
"extensions/copilot/src/telemetry-bridge.ts",
"extensions/copilot/src/user-input-bridge.ts",
"extensions/diffs/src/viewer-client.ts",
"extensions/diffs/src/viewer-payload.ts",
"extensions/matrix/src/plugin-entry.runtime.js",