From d50dbfc448f0f7e0da700d915e950981a1dd7fe4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 24 Aug 2026 19:01:35 +0530 Subject: [PATCH] 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. --- .../anthropic/agent-sdk-runtime-helpers.ts | 22 ++ .../anthropic/agent-sdk-user-input.test.ts | 141 ++++++++ extensions/anthropic/agent-sdk-user-input.ts | 115 +++++++ .../agent-sdk.runtime.permissions.test.ts | 314 ++++++++++++++++++ .../anthropic/agent-sdk.runtime.test.ts | 172 +--------- extensions/anthropic/agent-sdk.runtime.ts | 48 ++- src/agents/cli-runner/execute-plugin.test.ts | 91 +++++ src/agents/cli-runner/execute-plugin.ts | 108 ++++++ src/agents/cli-runner/types.ts | 5 + .../cli-backend-dispatch.ts | 2 + src/plugin-sdk/cli-backend.ts | 4 + src/plugins/cli-backend.types.ts | 29 ++ 12 files changed, 856 insertions(+), 195 deletions(-) create mode 100644 extensions/anthropic/agent-sdk-runtime-helpers.ts create mode 100644 extensions/anthropic/agent-sdk-user-input.test.ts create mode 100644 extensions/anthropic/agent-sdk-user-input.ts create mode 100644 extensions/anthropic/agent-sdk.runtime.permissions.test.ts diff --git a/extensions/anthropic/agent-sdk-runtime-helpers.ts b/extensions/anthropic/agent-sdk-runtime-helpers.ts new file mode 100644 index 000000000000..218cabecb946 --- /dev/null +++ b/extensions/anthropic/agent-sdk-runtime-helpers.ts @@ -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 } : {}), + }; +} diff --git a/extensions/anthropic/agent-sdk-user-input.test.ts b/extensions/anthropic/agent-sdk-user-input.test.ts new file mode 100644 index 000000000000..a32106244895 --- /dev/null +++ b/extensions/anthropic/agent-sdk-user-input.test.ts @@ -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(); + }); +}); diff --git a/extensions/anthropic/agent-sdk-user-input.ts b/extensions/anthropic/agent-sdk-user-input.ts new file mode 100644 index 000000000000..a5116cd4751a --- /dev/null +++ b/extensions/anthropic/agent-sdk-user-input.ts @@ -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>(); + return { + authorize(params: { + input: Record; + signal: AbortSignal; + toolUseId?: string; + }): Promise { + 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; + signal: AbortSignal; + toolUseId?: string; + }, +): Promise { + 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 = {}; + questions.forEach((question) => { + answers[question.question] = (result.answers[question.id] ?? []).join(", "); + }); + return { behavior: "allow", updatedInput: { ...params.input, answers } }; +} + +function readClaudeUserInputQuestions( + input: Record, +): 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; +} diff --git a/extensions/anthropic/agent-sdk.runtime.permissions.test.ts b/extensions/anthropic/agent-sdk.runtime.permissions.test.ts new file mode 100644 index 000000000000..a782c638c0a0 --- /dev/null +++ b/extensions/anthropic/agent-sdk.runtime.permissions.test.ts @@ -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 { + 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> = [SUCCESS_RESULT], + onQuery?: (options: Record) => Promise, +) { + queryMock.mockImplementation(({ options }: { options: Record }) => { + const stream = (async function* () { + await onQuery?.(options); + yield* messages; + })(); + return Object.assign(stream, { close: vi.fn() }); + }); +} + +async function collect(context: CliBackendExecuteContext): Promise { + for await (const record of executeClaudeAgentSdk(context)) { + void record; + } +} + +function sdkOptions(): Record { + const call = queryMock.mock.calls[0]?.[0] as { options?: Record } | undefined; + expect(call?.options).toBeDefined(); + return call?.options ?? {}; +} + +type SdkNativeToolCallback = ( + toolName: string, + input: Record, + details: { signal: AbortSignal; toolUseID: string; requestId?: string }, +) => Promise; + +function sdkNativeTool(options: Record): 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; + +function sdkPreToolUse(options: Record): 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(); + }); +}); diff --git a/extensions/anthropic/agent-sdk.runtime.test.ts b/extensions/anthropic/agent-sdk.runtime.test.ts index 9a261cd7afaa..0677a87d4214 100644 --- a/extensions/anthropic/agent-sdk.runtime.test.ts +++ b/extensions/anthropic/agent-sdk.runtime.test.ts @@ -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) => { diff --git a/extensions/anthropic/agent-sdk.runtime.ts b/extensions/anthropic/agent-sdk.runtime.ts index 61e86f8f8766..ea797ac50464 100644 --- a/extensions/anthropic/agent-sdk.runtime.ts +++ b/extensions/anthropic/agent-sdk.runtime.ts @@ -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; }; type ClaudeAgentSdkLiveTurn = ClaudeAgentSdkTurn & { @@ -99,13 +104,6 @@ type ClaudeAgentSdkSession = { const claudeAgentSdkSessions = new WeakMap(); -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(); diff --git a/src/agents/cli-runner/execute-plugin.test.ts b/src/agents/cli-runner/execute-plugin.test.ts index dca0f5f2e2f6..bc44802fc67f 100644 --- a/src/agents/cli-runner/execute-plugin.test.ts +++ b/src/agents/cli-runner/execute-plugin.test.ts @@ -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 }>(); + 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((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."; diff --git a/src/agents/cli-runner/execute-plugin.ts b/src/agents/cli-runner/execute-plugin.ts index 6b277a33b6a0..135b2e9f01c9 100644 --- a/src/agents/cli-runner/execute-plugin.ts +++ b/src/agents/cli-runner/execute-plugin.ts @@ -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 { + 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( iterator: AsyncIterator, 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](); diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index a6f6a8f59a08..794a75a1a650 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -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; + onBlockReply?: (payload: BlockReplyPayload, context?: BlockReplyContext) => void | Promise; onExecutionStarted?: () => void; onExecutionPhase?: (info: { phase: EmbeddedAgentExecutionPhase; diff --git a/src/agents/embedded-agent-runner/cli-backend-dispatch.ts b/src/agents/embedded-agent-runner/cli-backend-dispatch.ts index a1f6ca786377..b7e164913e06 100644 --- a/src/agents/embedded-agent-runner/cli-backend-dispatch.ts +++ b/src/agents/embedded-agent-runner/cli-backend-dispatch.ts @@ -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 diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index 7b6379f2db38..05cd80f81e6a 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -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"; diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index cb73413563b2..973c7e822a2b 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -172,6 +172,33 @@ export type CliBackendToolPermissionResult = | { behavior: "allow"; updatedInput: Record } | { 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 } + | { 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; + /** Closure-bound structured-input capability; retained copies fail after the run closes. */ + requestUserInput: (request: CliBackendUserInputRequest) => Promise; }; /** Plugin-owned runtime yielding the backend's existing structured stream records. */