fix(anthropic): show Claude questions as interactive prompts (#128729)

Route Claude Agent SDK AskUserQuestion through OpenClaw's shared structured-input flow across the Control UI and existing channel fallback surfaces.

Fixes #81099.
This commit is contained in:
Ayaan Zaidi
2026-08-24 19:01:35 +05:30
committed by GitHub
parent 0fbb5dd00e
commit d50dbfc448
12 changed files with 856 additions and 195 deletions
@@ -0,0 +1,22 @@
import { randomUUID } from "node:crypto";
import type { SDKUserMessage as ClaudeAgentSdkUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { CliBackendExecuteContext } from "openclaw/plugin-sdk/cli-backend";
export function splitClaudeToolNames(value: string): string[] {
return value
.split(",")
.map((name) => name.trim())
.filter(Boolean);
}
export function createClaudeAgentSdkUserMessage(
context: CliBackendExecuteContext,
): ClaudeAgentSdkUserMessage {
return {
type: "user",
message: { role: "user", content: context.prompt },
parent_tool_use_id: null,
uuid: randomUUID(),
...(context.sessionId ? { session_id: context.sessionId } : {}),
};
}
@@ -0,0 +1,141 @@
import type { CliBackendExecuteContext } from "openclaw/plugin-sdk/cli-backend";
import { describe, expect, it, vi } from "vitest";
import { createClaudeAgentSdkUserInputAuthorizer } from "./agent-sdk-user-input.js";
function createContext(
requestUserInput: CliBackendExecuteContext["requestUserInput"],
): CliBackendExecuteContext {
return {
command: "/usr/local/bin/claude",
args: [],
cwd: "/tmp",
env: {},
prompt: "test",
modelId: "claude-sonnet-4-6",
systemPrompt: "test",
useResume: false,
timeoutMs: 30_000,
requestToolPermission: vi.fn(async () => ({
behavior: "allow" as const,
updatedInput: {},
})),
requestUserInput,
};
}
const input = {
questions: [
{
header: "Test stack",
question: "Which test runner should we use?",
options: [
{ label: "Vitest", description: "Use the existing test stack." },
{ label: "Node test", description: "Use the built-in runner." },
],
multiSelect: false,
},
{
header: "Proof",
question: "Which proof should we collect?",
options: [
{ label: "Unit tests", description: "Exercise the adapter." },
{ label: "UI proof", description: "Capture the Control UI." },
],
multiSelect: true,
},
],
};
describe("Claude Agent SDK user input adapter", () => {
it("maps Claude questions and answers while deduplicating the SDK callbacks", async () => {
const requestUserInput = vi.fn(async () => ({
status: "answered" as const,
answers: {
question_1: ["Vitest"],
question_2: ["Unit tests", "UI proof"],
},
}));
const authorizer = createClaudeAgentSdkUserInputAuthorizer(createContext(requestUserInput));
const signal = new AbortController().signal;
const first = authorizer.authorize({ input, signal, toolUseId: "claude-question-1" });
const second = authorizer.authorize({ input, signal, toolUseId: "claude-question-1" });
await expect(first).resolves.toEqual({
behavior: "allow",
updatedInput: {
...input,
answers: {
"Which test runner should we use?": "Vitest",
"Which proof should we collect?": "Unit tests, UI proof",
},
},
});
await expect(second).resolves.toEqual(await first);
expect(requestUserInput).toHaveBeenCalledOnce();
expect(requestUserInput).toHaveBeenCalledWith({
toolName: "AskUserQuestion",
intro: "Claude needs input:",
toolCallId: "claude-question-1",
abortSignal: signal,
questions: [
{
id: "question_1",
header: "Test stack",
question: "Which test runner should we use?",
multiSelect: false,
isOther: true,
options: [
{ label: "Vitest", description: "Use the existing test stack." },
{ label: "Node test", description: "Use the built-in runner." },
],
},
{
id: "question_2",
header: "Proof",
question: "Which proof should we collect?",
multiSelect: true,
isOther: true,
options: [
{ label: "Unit tests", description: "Exercise the adapter." },
{ label: "UI proof", description: "Capture the Control UI." },
],
},
],
});
});
it("returns actionable denial guidance when the operator skips", async () => {
const authorizer = createClaudeAgentSdkUserInputAuthorizer(
createContext(
vi.fn(async () => ({
status: "cancelled" as const,
message: "The operator skipped this question.",
})),
),
);
await expect(
authorizer.authorize({ input, signal: new AbortController().signal }),
).resolves.toEqual({
behavior: "deny",
message: "The operator skipped this question. Continue with your best judgment.",
});
});
it("rejects malformed questions before invoking the host", async () => {
const requestUserInput = vi.fn();
const authorizer = createClaudeAgentSdkUserInputAuthorizer(createContext(requestUserInput));
await expect(
authorizer.authorize({
input: { questions: [{ header: "Too long for Claude", question: "Missing options" }] },
signal: new AbortController().signal,
}),
).resolves.toEqual({
behavior: "deny",
message: "OpenClaw rejected malformed Claude user questions.",
});
expect(requestUserInput).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
import type { PermissionResult as ClaudeAgentSdkPermissionResult } from "@anthropic-ai/claude-agent-sdk";
import type {
CliBackendExecuteContext,
CliBackendUserInputQuestion,
} from "openclaw/plugin-sdk/cli-backend";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export function createClaudeAgentSdkUserInputAuthorizer(context: CliBackendExecuteContext) {
const requests = new Map<string, Promise<ClaudeAgentSdkPermissionResult>>();
return {
authorize(params: {
input: Record<string, unknown>;
signal: AbortSignal;
toolUseId?: string;
}): Promise<ClaudeAgentSdkPermissionResult> {
const existing = params.toolUseId ? requests.get(params.toolUseId) : undefined;
if (existing) {
return existing;
}
const request = runClaudeUserInput(context, params);
if (params.toolUseId) {
requests.set(params.toolUseId, request);
}
return request;
},
};
}
async function runClaudeUserInput(
context: CliBackendExecuteContext,
params: {
input: Record<string, unknown>;
signal: AbortSignal;
toolUseId?: string;
},
): Promise<ClaudeAgentSdkPermissionResult> {
const questions = readClaudeUserInputQuestions(params.input);
if (!questions) {
return { behavior: "deny", message: "OpenClaw rejected malformed Claude user questions." };
}
const result = await context.requestUserInput({
toolName: "AskUserQuestion",
questions,
intro: "Claude needs input:",
...(params.toolUseId ? { toolCallId: params.toolUseId } : {}),
abortSignal: params.signal,
});
if (result.status !== "answered") {
return {
behavior: "deny",
message: `${result.message} Continue with your best judgment.`,
};
}
const answers: Record<string, string> = {};
questions.forEach((question) => {
answers[question.question] = (result.answers[question.id] ?? []).join(", ");
});
return { behavior: "allow", updatedInput: { ...params.input, answers } };
}
function readClaudeUserInputQuestions(
input: Record<string, unknown>,
): CliBackendUserInputQuestion[] | undefined {
const rawQuestions = input.questions;
if (!Array.isArray(rawQuestions) || rawQuestions.length < 1 || rawQuestions.length > 4) {
return undefined;
}
const questions: CliBackendUserInputQuestion[] = [];
for (const [index, rawQuestion] of rawQuestions.entries()) {
if (!isRecord(rawQuestion)) {
return undefined;
}
const question = readBoundedText(rawQuestion.question, 4_096);
const header = readBoundedText(rawQuestion.header, 12);
const rawOptions = rawQuestion.options;
if (
!question ||
!header ||
!Array.isArray(rawOptions) ||
rawOptions.length < 2 ||
rawOptions.length > 4 ||
typeof rawQuestion.multiSelect !== "boolean"
) {
return undefined;
}
const options: Array<{ label: string; description?: string }> = [];
for (const rawOption of rawOptions) {
if (!isRecord(rawOption)) {
return undefined;
}
const label = readBoundedText(rawOption.label, 256);
const description = readBoundedText(rawOption.description, 1_024);
if (!label || !description) {
return undefined;
}
options.push({ label, description });
}
questions.push({
id: `question_${index + 1}`,
header,
question,
multiSelect: rawQuestion.multiSelect,
isOther: true,
options,
});
}
return questions;
}
function readBoundedText(value: unknown, maxLength: number): string | undefined {
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
return undefined;
}
return value;
}
@@ -0,0 +1,314 @@
import type { PermissionResult as ClaudeAgentSdkPermissionResult } from "@anthropic-ai/claude-agent-sdk";
import type { CliBackendExecuteContext } from "openclaw/plugin-sdk/cli-backend";
import { afterEach, describe, expect, it, vi } from "vitest";
import { executeClaudeAgentSdk } from "./agent-sdk.runtime.js";
const { queryMock } = vi.hoisted(() => ({ queryMock: vi.fn() }));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ query: queryMock }));
const SUCCESS_RESULT = {
type: "result",
subtype: "success",
is_error: false,
result: "ok",
session_id: "a174e16f-b6e9-48da-ad5a-c437dfc2f9b4",
};
function createContext(
overrides: Partial<CliBackendExecuteContext> = {},
): CliBackendExecuteContext {
return {
command: "/usr/local/bin/claude",
args: ["-p"],
cwd: "/tmp/openclaw-workspace",
env: { PATH: "/usr/local/bin:/usr/bin" },
prompt: "Remember the launch code.",
modelId: "claude-sonnet-4-6",
systemPrompt: "Follow the OpenClaw execution policy.",
useResume: false,
timeoutMs: 30_000,
executionMode: "agent",
requestToolPermission: vi.fn(async () => ({
behavior: "deny" as const,
message: "OpenClaw denied this action.",
})),
requestUserInput: vi.fn(async () => ({
status: "cancelled" as const,
message: "OpenClaw cancelled this question.",
})),
...overrides,
};
}
function useSdkMessages(
messages: ReadonlyArray<Record<string, unknown>> = [SUCCESS_RESULT],
onQuery?: (options: Record<string, unknown>) => Promise<void>,
) {
queryMock.mockImplementation(({ options }: { options: Record<string, unknown> }) => {
const stream = (async function* () {
await onQuery?.(options);
yield* messages;
})();
return Object.assign(stream, { close: vi.fn() });
});
}
async function collect(context: CliBackendExecuteContext): Promise<void> {
for await (const record of executeClaudeAgentSdk(context)) {
void record;
}
}
function sdkOptions(): Record<string, unknown> {
const call = queryMock.mock.calls[0]?.[0] as { options?: Record<string, unknown> } | undefined;
expect(call?.options).toBeDefined();
return call?.options ?? {};
}
type SdkNativeToolCallback = (
toolName: string,
input: Record<string, unknown>,
details: { signal: AbortSignal; toolUseID: string; requestId?: string },
) => Promise<ClaudeAgentSdkPermissionResult>;
function sdkNativeTool(options: Record<string, unknown>): SdkNativeToolCallback {
return options.canUseTool as SdkNativeToolCallback;
}
type SdkPreToolUseCallback = (
input: {
hook_event_name: "PreToolUse";
tool_name: string;
tool_input: unknown;
tool_use_id: string;
},
toolUseId: string | undefined,
options: { signal: AbortSignal },
) => Promise<unknown>;
function sdkPreToolUse(options: Record<string, unknown>): SdkPreToolUseCallback {
const hooks = options.hooks as { PreToolUse?: Array<{ hooks?: SdkPreToolUseCallback[] }> };
const callback = hooks.PreToolUse?.[0]?.hooks?.[0];
if (!callback) {
throw new Error("Claude Agent SDK did not register its native permission hook.");
}
return callback;
}
afterEach(() => {
queryMock.mockReset();
vi.restoreAllMocks();
});
describe("Anthropic Agent SDK native permission bridge", () => {
it("routes AskUserQuestion through structured input instead of tool approval", async () => {
const requestToolPermission = vi.fn();
const requestUserInput = vi.fn(async () => ({
status: "answered" as const,
answers: { question_1: ["Shared flow"] },
}));
let decision: unknown;
useSdkMessages([SUCCESS_RESULT], async (options) => {
decision = await sdkNativeTool(options)(
"AskUserQuestion",
{
questions: [
{
header: "Approach",
question: "Which implementation should Claude use?",
options: [
{ label: "Shared flow", description: "Use OpenClaw's existing question flow." },
{ label: "Claude-only", description: "Build a provider-specific path." },
],
multiSelect: false,
},
],
},
{
signal: new AbortController().signal,
toolUseID: "ask-user-question",
},
);
});
await collect(createContext({ requestToolPermission, requestUserInput }));
expect(decision).toEqual({
behavior: "allow",
updatedInput: {
questions: expect.any(Array),
answers: { "Which implementation should Claude use?": "Shared flow" },
},
});
expect(requestToolPermission).not.toHaveBeenCalled();
expect(requestUserInput).toHaveBeenCalledOnce();
});
it("enforces native tool policy before user settings can shadow the permission callback", async () => {
const requestToolPermission = vi.fn(async () => ({
behavior: "deny" as const,
message: "The session policy denied native execution.",
}));
let nativeDecision: unknown;
let gatewayDecision: unknown;
let malformedDecision: unknown;
useSdkMessages([SUCCESS_RESULT], async (options) => {
const hook = sdkPreToolUse(options);
const signal = new AbortController().signal;
nativeDecision = await hook(
{
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: { command: "cat private.txt" },
tool_use_id: "native-tool-shadowed",
},
"native-tool-shadowed",
{ signal },
);
gatewayDecision = await hook(
{
hook_event_name: "PreToolUse",
tool_name: "mcp__openclaw__message",
tool_input: { action: "send" },
tool_use_id: "gateway-tool-owned",
},
"gateway-tool-owned",
{ signal },
);
malformedDecision = await hook(
{
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: "not-an-object",
tool_use_id: "malformed-native-tool",
},
"malformed-native-tool",
{ signal },
);
});
await collect(createContext({ requestToolPermission }));
expect(nativeDecision).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "The session policy denied native execution.",
},
});
expect(gatewayDecision).toEqual({ continue: true });
expect(malformedDecision).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "OpenClaw rejected malformed native tool input.",
},
});
expect(requestToolPermission).toHaveBeenCalledOnce();
expect(requestToolPermission).toHaveBeenCalledWith({
toolName: "Bash",
toolInput: { command: "cat private.txt" },
toolCallId: "native-tool-shadowed",
abortSignal: expect.any(AbortSignal),
});
});
it("keeps bypass-shaped backend arguments behind the host permission callback", async () => {
const requestToolPermission = vi.fn(async () => ({
behavior: "deny" as const,
message: "The session policy denied native execution.",
}));
let decision: unknown;
useSdkMessages([SUCCESS_RESULT], async (options) => {
decision = await sdkNativeTool(options)(
"Bash",
{ command: "cat private.txt" },
{
signal: new AbortController().signal,
toolUseID: "native-tool-bypass",
requestId: "approval-bypass",
},
);
});
await collect(
createContext({
args: ["-p", "--permission-mode", "bypassPermissions"],
requestToolPermission,
}),
);
expect(sdkOptions().permissionMode).toBe("default");
expect(sdkOptions()).not.toHaveProperty("allowDangerouslySkipPermissions");
expect(decision).toEqual({
behavior: "deny",
message: "The session policy denied native execution.",
});
expect(requestToolPermission).toHaveBeenCalledOnce();
});
it.each([
{
name: "forwards allowed decisions and exact host inputs",
resolve: async () => ({
behavior: "allow" as const,
updatedInput: { command: "echo approved" },
}),
expected: { behavior: "allow", updatedInput: { command: "echo approved" } },
},
{
name: "preserves a denied host decision",
resolve: async () => ({
behavior: "deny" as const,
message: "OpenClaw exec policy denied this action.",
}),
expected: { behavior: "deny", message: "OpenClaw exec policy denied this action." },
},
{
name: "fails closed when the host approval owner is unavailable",
resolve: async () => {
throw new Error("The Gateway approval owner is unavailable.");
},
expected: { behavior: "deny", message: "OpenClaw could not authorize this tool call." },
},
])("$name and fences the retained callback after closure", async ({ resolve, expected }) => {
const requestToolPermission = vi.fn(resolve);
const signal = new AbortController().signal;
const input = { command: "echo approved" };
let decision: unknown;
let callback: SdkNativeToolCallback | undefined;
useSdkMessages([SUCCESS_RESULT], async (options) => {
callback = sdkNativeTool(options);
decision = await callback("Bash", input, {
signal,
toolUseID: "native-tool-1",
requestId: "approval-1",
});
});
await collect(createContext({ requestToolPermission }));
expect(decision).toEqual(expected);
expect(requestToolPermission).toHaveBeenCalledWith({
toolName: "Bash",
toolInput: input,
toolCallId: "native-tool-1",
abortSignal: signal,
});
await expect(
callback?.(
"Bash",
{ command: "echo stale" },
{
signal,
toolUseID: "native-tool-stale",
},
),
).resolves.toEqual({
behavior: "deny",
message: "The OpenClaw run is no longer active.",
});
expect(requestToolPermission).toHaveBeenCalledOnce();
});
});
+4 -168
View File
@@ -67,6 +67,10 @@ function createContext(
behavior: "deny" as const,
message: "OpenClaw denied this action.",
})),
requestUserInput: vi.fn(async () => ({
status: "cancelled" as const,
message: "OpenClaw cancelled this question.",
})),
...overrides,
};
}
@@ -865,174 +869,6 @@ describe("Anthropic Agent SDK runtime ownership", () => {
expect(sdkOptions().allowedTools).not.toContain("Edit");
});
it("enforces native tool policy before user settings can shadow the permission callback", async () => {
const requestToolPermission = vi.fn(async () => ({
behavior: "deny" as const,
message: "The session policy denied native execution.",
}));
let nativeDecision: unknown;
let gatewayDecision: unknown;
let malformedDecision: unknown;
useSdkMessages([SUCCESS_RESULT], async (options) => {
const hook = sdkPreToolUse(options);
const signal = new AbortController().signal;
nativeDecision = await hook(
{
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: { command: "cat private.txt" },
tool_use_id: "native-tool-shadowed",
},
"native-tool-shadowed",
{ signal },
);
gatewayDecision = await hook(
{
hook_event_name: "PreToolUse",
tool_name: "mcp__openclaw__message",
tool_input: { action: "send" },
tool_use_id: "gateway-tool-owned",
},
"gateway-tool-owned",
{ signal },
);
malformedDecision = await hook(
{
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: "not-an-object",
tool_use_id: "malformed-native-tool",
},
"malformed-native-tool",
{ signal },
);
});
await collect(createContext({ requestToolPermission }));
expect(nativeDecision).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "The session policy denied native execution.",
},
});
expect(gatewayDecision).toEqual({ continue: true });
expect(malformedDecision).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "OpenClaw rejected malformed native tool input.",
},
});
expect(requestToolPermission).toHaveBeenCalledOnce();
expect(requestToolPermission).toHaveBeenCalledWith({
toolName: "Bash",
toolInput: { command: "cat private.txt" },
toolCallId: "native-tool-shadowed",
abortSignal: expect.any(AbortSignal),
});
});
it("keeps bypass-shaped backend arguments behind the host permission callback", async () => {
const requestToolPermission = vi.fn(async () => ({
behavior: "deny" as const,
message: "The session policy denied native execution.",
}));
let decision: unknown;
useSdkMessages([SUCCESS_RESULT], async (options) => {
decision = await sdkNativeTool(options)(
"Bash",
{ command: "cat private.txt" },
{
signal: new AbortController().signal,
toolUseID: "native-tool-bypass",
requestId: "approval-bypass",
},
);
});
await collect(
createContext({
args: ["-p", "--permission-mode", "bypassPermissions"],
requestToolPermission,
}),
);
expect(sdkOptions().permissionMode).toBe("default");
expect(sdkOptions()).not.toHaveProperty("allowDangerouslySkipPermissions");
expect(decision).toEqual({
behavior: "deny",
message: "The session policy denied native execution.",
});
expect(requestToolPermission).toHaveBeenCalledOnce();
});
it.each([
{
name: "forwards allowed decisions and exact host inputs",
resolve: async () => ({
behavior: "allow" as const,
updatedInput: { command: "echo approved" },
}),
expected: { behavior: "allow", updatedInput: { command: "echo approved" } },
},
{
name: "preserves a denied host decision",
resolve: async () => ({
behavior: "deny" as const,
message: "OpenClaw exec policy denied this action.",
}),
expected: { behavior: "deny", message: "OpenClaw exec policy denied this action." },
},
{
name: "fails closed when the host approval owner is unavailable",
resolve: async () => {
throw new Error("The Gateway approval owner is unavailable.");
},
expected: { behavior: "deny", message: "OpenClaw could not authorize this tool call." },
},
])("$name and fences the retained callback after closure", async ({ resolve, expected }) => {
const requestToolPermission = vi.fn(resolve);
const signal = new AbortController().signal;
const input = { command: "echo approved" };
let decision: unknown;
let callback: SdkNativeToolCallback | undefined;
useSdkMessages([SUCCESS_RESULT], async (options) => {
callback = sdkNativeTool(options);
decision = await callback("Bash", input, {
signal,
toolUseID: "native-tool-1",
requestId: "approval-1",
});
});
await collect(createContext({ requestToolPermission }));
expect(decision).toEqual(expected);
expect(requestToolPermission).toHaveBeenCalledWith({
toolName: "Bash",
toolInput: input,
toolCallId: "native-tool-1",
abortSignal: signal,
});
await expect(
callback?.(
"Bash",
{ command: "echo stale" },
{
signal,
toolUseID: "native-tool-stale",
},
),
).resolves.toEqual({
behavior: "deny",
message: "The OpenClaw run is no longer active.",
});
expect(requestToolPermission).toHaveBeenCalledOnce();
});
it.each([429, 529])(
"yields an HTTP %i error-marked success before surfacing the SDK's later exit error",
async (apiErrorStatus) => {
+21 -27
View File
@@ -5,7 +5,6 @@ import type {
Options as ClaudeAgentSdkOptions,
PermissionResult as ClaudeAgentSdkPermissionResult,
Query as ClaudeAgentSdkQuery,
SDKUserMessage as ClaudeAgentSdkUserMessage,
SpawnOptions as ClaudeAgentSdkSpawnOptions,
SpawnedProcess as ClaudeAgentSdkSpawnedProcess,
} from "@anthropic-ai/claude-agent-sdk";
@@ -17,6 +16,11 @@ import type {
} from "openclaw/plugin-sdk/cli-backend";
import { killProcessTree } from "openclaw/plugin-sdk/process-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
createClaudeAgentSdkUserMessage,
splitClaudeToolNames,
} from "./agent-sdk-runtime-helpers.js";
import { createClaudeAgentSdkUserInputAuthorizer } from "./agent-sdk-user-input.js";
const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] satisfies NonNullable<
ClaudeAgentSdkOptions["effort"]
@@ -75,6 +79,7 @@ type ClaudeAgentSdkSecretInput = {
type ClaudeAgentSdkTurn = {
context: CliBackendExecuteContext;
controller: AbortController;
userInput: ReturnType<typeof createClaudeAgentSdkUserInputAuthorizer>;
};
type ClaudeAgentSdkLiveTurn = ClaudeAgentSdkTurn & {
@@ -99,13 +104,6 @@ type ClaudeAgentSdkSession = {
const claudeAgentSdkSessions = new WeakMap<CliBackendLiveSessionHandle, ClaudeAgentSdkSession>();
function splitClaudeToolNames(value: string): string[] {
return value
.split(",")
.map((name) => name.trim())
.filter(Boolean);
}
function spawnClaudeAgentSdkProcess(
options: ClaudeAgentSdkSpawnOptions,
secretInput?: ClaudeAgentSdkSecretInput,
@@ -178,12 +176,19 @@ async function authorizeClaudeAgentSdkTool(params: {
return { behavior: "deny", message: "The OpenClaw run is no longer active." };
}
try {
const decision = await turn.context.requestToolPermission({
toolName: params.toolName,
toolInput: params.input,
...(params.toolUseId ? { toolCallId: params.toolUseId } : {}),
abortSignal: params.signal,
});
const decision =
params.toolName === "AskUserQuestion"
? await turn.userInput.authorize({
input: params.input,
signal: params.signal,
...(params.toolUseId ? { toolUseId: params.toolUseId } : {}),
})
: await turn.context.requestToolPermission({
toolName: params.toolName,
toolInput: params.input,
...(params.toolUseId ? { toolCallId: params.toolUseId } : {}),
abortSignal: params.signal,
});
if (params.currentTurn() !== turn || params.signal.aborted || turn.controller.signal.aborted) {
return { behavior: "deny", message: "The OpenClaw run is no longer active." };
}
@@ -445,18 +450,6 @@ function resolveClaudeAgentSdkOptions(
return options;
}
function createClaudeAgentSdkUserMessage(
context: CliBackendExecuteContext,
): ClaudeAgentSdkUserMessage {
return {
type: "user",
message: { role: "user", content: context.prompt },
parent_tool_use_id: null,
uuid: randomUUID(),
...(context.sessionId ? { session_id: context.sessionId } : {}),
};
}
function closeClaudeAgentSdkSession(
session: ClaudeAgentSdkSession,
_reason: CliBackendLiveSessionCloseReason,
@@ -608,6 +601,7 @@ async function* executeClaudeAgentSdkLiveTurn(
const turn: ClaudeAgentSdkLiveTurn = {
context,
controller: new AbortController(),
userInput: createClaudeAgentSdkUserInputAuthorizer(context),
events: new PassThrough({ objectMode: true }),
sawTerminalResult: false,
};
@@ -659,7 +653,6 @@ async function* executeClaudeAgentSdkLiveTurn(
}
}
/** Execute Claude Code through Anthropic's maintained SDK transport and private auth boundary. */
export async function* executeClaudeAgentSdk(
context: CliBackendExecuteContext,
secretInput?: ClaudeAgentSdkSecretInput,
@@ -674,6 +667,7 @@ export async function* executeClaudeAgentSdk(
let activeTurn: ClaudeAgentSdkTurn | undefined = {
context,
controller,
userInput: createClaudeAgentSdkUserInputAuthorizer(context),
};
let sawTerminalResult = false;
const abort = () => controller.abort();
@@ -219,10 +219,101 @@ describe("plugin-owned CLI execution host boundary", () => {
useResume: false,
env: { PATH: "/bin:/usr/bin", OPENCLAW_TEST_MARKER: "host-owned" },
requestToolPermission: expect.any(Function),
requestUserInput: expect.any(Function),
}),
);
});
it("runs plugin user questions through the shared Gateway question flow", async () => {
const { context } = await createExecution({
runId: "plugin-user-input",
nativeTools: ["AskUserQuestion"],
});
const onBlockReply = vi.fn(async () => {});
context.params.onBlockReply = onBlockReply;
const requests = new Map<string, { questions: Array<{ questionId: string }> }>();
mockCallGatewayTool.mockImplementation(async (method, _opts, rawParams) => {
const params = rawParams as { id: string; questions?: Array<{ questionId: string }> };
if (method === "question.request") {
requests.set(params.id, { questions: params.questions ?? [] });
return { id: params.id };
}
if (method === "question.waitAnswer") {
const request = requests.get(params.id);
await new Promise<void>((resolve) => {
setTimeout(resolve, 5);
});
return {
status: "answered",
answers: {
answers: Object.fromEntries(
(request?.questions ?? []).map((question) => [
question.questionId,
[question.questionId],
]),
),
},
};
}
if (method === "question.resolve") {
return { status: "cancelled" };
}
throw new Error(`Unexpected Gateway method: ${method}`);
});
let result: unknown;
await runPlugin(context, async function* (execution) {
result = await execution.requestUserInput({
toolName: "AskUserQuestion",
toolCallId: "claude-question",
questions: [
{
id: "one",
header: "One",
question: "First question?",
isOther: true,
options: [{ label: "A" }, { label: "B" }],
},
{
id: "two",
header: "Two",
question: "Second question?",
isOther: true,
options: [{ label: "A" }, { label: "B" }],
},
{
id: "three",
header: "Three",
question: "Third question?",
isOther: true,
options: [{ label: "A" }, { label: "B" }],
},
{
id: "four",
header: "Four",
question: "Fourth question?",
isOther: true,
options: [{ label: "A" }, { label: "B" }],
},
],
});
yield SUCCESS_RESULT;
});
expect(result).toEqual({
status: "answered",
answers: {
one: ["one"],
two: ["two"],
three: ["three"],
four: ["four"],
},
});
expect([...requests.keys()]).toEqual(["claude-question:0", "claude-question:1"]);
expect([...requests.values()].map((request) => request.questions.length)).toEqual([3, 1]);
expect(onBlockReply).toHaveBeenCalledTimes(2);
});
it("restarts true fresh sessions while preserving legitimate no-resume warm reuse", async () => {
const reseed = await createExecution({ runId: "plugin-fresh-reseed" });
reseed.context.openClawHistoryPrompt = "Previously recorded bounded conversation.";
+108
View File
@@ -8,12 +8,17 @@ import type {
CliBackendExecute,
CliBackendToolPermissionRequest,
CliBackendToolPermissionResult,
CliBackendUserInputRequest,
CliBackendUserInputResult,
} from "../../plugins/cli-backend.types.js";
import type { RunExit, TerminationReason } from "../../process/supervisor/types.js";
import { resolveAdmittedRunActiveAssertion } from "../admitted-run-context.js";
import type { CliTerminalInterruption } from "../cli-output-contracts.js";
import { resolveExecDefaults } from "../exec-defaults.js";
import { isSignalTimeoutReason, type FailoverError } from "../failover-error.js";
import { runStructuredInput } from "../harness/structured-input-execution.js";
import { compileStructuredInputQuestions } from "../harness/structured-input.js";
import { callGatewayTool } from "../tools/gateway.js";
import {
closeCliLiveSession,
createCliLiveSessionCapability,
@@ -121,6 +126,102 @@ function createPluginToolPermissionHandler(params: {
};
}
function cancelUserInput(message: string): CliBackendUserInputResult {
return { status: "cancelled", message };
}
function createPluginUserInputHandler(params: {
context: PreparedCliRunContext;
abortSignal: AbortSignal;
onPendingInput: (delta: 1 | -1) => void;
}): (request: CliBackendUserInputRequest) => Promise<CliBackendUserInputResult> {
const run = params.context.params;
return async (request) => {
const signal = request.abortSignal
? AbortSignal.any([params.abortSignal, request.abortSignal])
: params.abortSignal;
const assertActive = resolveAdmittedRunActiveAssertion(run.admittedRunContext, signal);
if (!assertActive) {
return cancelUserInput(
"OpenClaw cancelled operator input: the admitted run is no longer active.",
);
}
try {
assertActive();
} catch {
return cancelUserInput(
"OpenClaw cancelled operator input: the admitted run is no longer active.",
);
}
const toolName = request.toolName.trim();
if (
!toolName ||
(run.cliToolAvailability && !run.cliToolAvailability.native.includes(toolName))
) {
return cancelUserInput(
toolName
? `OpenClaw cancelled operator input from ${toolName}: it is unavailable to this run.`
: "OpenClaw cancelled an unnamed operator input request.",
);
}
if (request.questions.length === 0 || request.questions.length > 12) {
return cancelUserInput("OpenClaw cancelled an invalid operator input request.");
}
params.onPendingInput(1);
try {
const result = await runStructuredInput({
input: compileStructuredInputQuestions({
questions: request.questions.map((question) => ({
...question,
isSecret: false,
})),
intro: request.intro?.trim() || "Agent needs input:",
}),
sessionKey: run.sessionKey ?? run.sessionId,
agentId: run.agentId,
runId: run.runId,
timeoutMs: run.timeoutMs,
gatewayCall: callGatewayTool,
delivery: {
onBlockReply: run.onBlockReply,
onPartialReply: run.onPartialReply,
},
signal,
isActive: () => {
try {
assertActive();
return true;
} catch {
return false;
}
},
questionId: request.toolCallId ? (batch) => `${request.toolCallId}:${batch}` : undefined,
});
try {
assertActive();
} catch {
return cancelUserInput(
"OpenClaw cancelled operator input: the admitted run closed before the answer was committed.",
);
}
return result.status === "answered"
? { status: "answered", answers: result.answers }
: cancelUserInput(
result.message ??
"OpenClaw cancelled operator input; continue with your best judgment.",
);
} catch {
return cancelUserInput(
"OpenClaw could not collect operator input; continue with your best judgment.",
);
} finally {
params.onPendingInput(-1);
}
};
}
function waitForIteratorValue<T>(
iterator: AsyncIterator<T>,
signal: AbortSignal,
@@ -329,6 +430,13 @@ export async function executePluginOwnedProcess(params: {
outstanding.approvals = Math.max(0, outstanding.approvals + delta);
},
}),
requestUserInput: createPluginUserInputHandler({
context: params.context,
abortSignal: signal,
onPendingInput: (delta) => {
outstanding.approvals = Math.max(0, outstanding.approvals + delta);
},
}),
});
iterator = execution[Symbol.asyncIterator]();
+5
View File
@@ -6,6 +6,8 @@ import type {
* Shared types for preparing and executing CLI-backed agent runs.
*/
import type {
BlockReplyContext,
PartialReplyPayload,
SourceReplyDeliveryMode,
TaskSuggestionDeliveryMode,
} from "../../auto-reply/get-reply-options.types.js";
@@ -47,6 +49,7 @@ import type { ResolvedCliBackend } from "../cli-backends.js";
import type { CliSessionReuseResult } from "../cli-session.js";
import type { ContextWindowInfo } from "../context-window-guard.js";
import type { FailoverReason } from "../embedded-agent-helpers.js";
import type { BlockReplyPayload } from "../embedded-agent-payloads.js";
import type { EmbeddedAgentExecutionPhase } from "../embedded-agent-runner/execution-phase.js";
import type {
CurrentInboundPromptContext,
@@ -257,6 +260,8 @@ export type RunCliAgentParams = {
};
disableTools?: boolean;
abortSignal?: AbortSignal;
onPartialReply?: (payload: PartialReplyPayload) => boolean | void | Promise<boolean | void>;
onBlockReply?: (payload: BlockReplyPayload, context?: BlockReplyContext) => void | Promise<void>;
onExecutionStarted?: () => void;
onExecutionPhase?: (info: {
phase: EmbeddedAgentExecutionPhase;
@@ -243,6 +243,8 @@ async function runEmbeddedAgentViaCliBackend(
bootstrapContextMode: params.bootstrapContextMode,
bootstrapContextRunKind: params.bootstrapContextRunKind,
abortSignal: params.abortSignal,
onBlockReply: params.onBlockReply,
onPartialReply: params.onPartialReply,
onExecutionPhase: params.onExecutionPhase,
cliToolAvailability,
// One-shot helper run: fresh CLI process, no warm live session left
+4
View File
@@ -27,6 +27,10 @@ export type {
CliBackendToolPermissionRequest,
CliBackendToolPermissionResult,
CliBackendThinkingLevel,
CliBackendUserInputOption,
CliBackendUserInputQuestion,
CliBackendUserInputRequest,
CliBackendUserInputResult,
} from "../plugins/cli-backend.types.js";
export type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js";
export { CliBackendAuthProfilePreparationError } from "../plugins/cli-backend-errors.js";
+29
View File
@@ -172,6 +172,33 @@ export type CliBackendToolPermissionResult =
| { behavior: "allow"; updatedInput: Record<string, unknown> }
| { behavior: "deny"; message: string };
export type CliBackendUserInputOption = {
label: string;
description?: string;
};
export type CliBackendUserInputQuestion = {
id: string;
header: string;
question: string;
multiSelect?: boolean;
isOther?: boolean;
options?: readonly CliBackendUserInputOption[] | null;
};
/** Structured operator input requested by a plugin-owned native runtime. */
export type CliBackendUserInputRequest = {
toolName: string;
questions: readonly CliBackendUserInputQuestion[];
intro?: string;
toolCallId?: string;
abortSignal?: AbortSignal;
};
export type CliBackendUserInputResult =
| { status: "answered"; answers: Record<string, string[]> }
| { status: "cancelled"; message: string };
/** Lifecycle reasons accepted by a plugin-owned reusable execution process. */
export type CliBackendLiveSessionCloseReason =
| "idle"
@@ -219,6 +246,8 @@ export type CliBackendExecuteContext = {
requestToolPermission: (
request: CliBackendToolPermissionRequest,
) => Promise<CliBackendToolPermissionResult>;
/** Closure-bound structured-input capability; retained copies fail after the run closes. */
requestUserInput: (request: CliBackendUserInputRequest) => Promise<CliBackendUserInputResult>;
};
/** Plugin-owned runtime yielding the backend's existing structured stream records. */