diff --git a/src/agents/bash-tools.exec-host-gateway.test.ts b/src/agents/bash-tools.exec-host-gateway.test.ts index e2f16d400a1e..95cc758827a5 100644 --- a/src/agents/bash-tools.exec-host-gateway.test.ts +++ b/src/agents/bash-tools.exec-host-gateway.test.ts @@ -1069,6 +1069,51 @@ EOF`, expect(approvalInput?.outcome?.exitCode).toBe(0); }); + it("uses async agent followups for explicit webchat approval mode", async () => { + buildExecApprovalFollowupTargetMock.mockImplementation((value) => value); + resolveExecHostApprovalContextMock.mockReturnValue({ + approvals: { allowlist: [], file: { version: 1, agents: {} } }, + hostSecurity: "allowlist", + hostAsk: "always", + askFallback: "deny", + }); + resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); + createExecApprovalDecisionStateMock.mockReturnValue({ + baseDecision: { timedOut: false }, + approvedByAsk: true, + deniedReason: null, + }); + runExecProcessMock.mockResolvedValue({ + session: { id: "sess-1" }, + promise: Promise.resolve({ + status: "completed", + exitCode: 0, + timedOut: false, + aggregated: "done", + }), + }); + + const result = await runGatewayAllowlist({ + command: "openclaw sessions export-trajectory --json", + approvalFollowupMode: "agent", + sessionId: "approval-session", + sessionStore: "/tmp/openclaw-sessions.json", + turnSourceChannel: "webchat", + }); + + expect(result.pendingResult?.details.status).toBe("approval-pending"); + await vi.waitFor(() => { + expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1); + }); + expect(requireBuildFollowupTargetInput(0)).toMatchObject({ + direct: false, + expectedSessionId: "approval-session", + sessionStore: "/tmp/openclaw-sessions.json", + }); + expect(requireSentFollowupTarget(0)?.direct).toBe(false); + expect(requireSentFollowupText(0)).toContain("done"); + }); + it("waits inline for webchat approval so the exec tool can return real output to the model", async () => { resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); createExecApprovalDecisionStateMock.mockReturnValue({ diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 21ac600744ba..b028e0585593 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -295,7 +295,7 @@ function shouldAwaitGatewayApprovalInline(params: { turnSourceChannel?: string; approvalFollowupMode?: "agent" | "direct"; }): boolean { - if (params.approvalFollowupMode === "direct") { + if (params.approvalFollowupMode !== undefined) { return false; } return normalizeMessageChannel(params.turnSourceChannel) === INTERNAL_MESSAGE_CHANNEL; diff --git a/src/auto-reply/reply/commands-export-trajectory.test.ts b/src/auto-reply/reply/commands-export-trajectory.test.ts index 3aec7fd244d7..0a6349761cf1 100644 --- a/src/auto-reply/reply/commands-export-trajectory.test.ts +++ b/src/auto-reply/reply/commands-export-trajectory.test.ts @@ -89,7 +89,11 @@ function makeTempDir(): string { function makeParams(workspaceDir = makeTempDir()): HandleCommandsParams { return { - cfg: {}, + cfg: { + session: { + store: "/tmp/openclaw-sessions.json", + }, + }, ctx: { SessionKey: "agent:main:slash-session", AccountId: "account-1", @@ -370,6 +374,9 @@ describe("buildExportTrajectoryCommandReply", () => { expect(execCall.defaults.security).toBe("allowlist"); expect(execCall.defaults.ask).toBe("always"); expect(execCall.defaults.trigger).toBe("export-trajectory"); + expect(execCall.defaults.approvalFollowupMode).toBe("agent"); + expect(execCall.defaults.sessionId).toBe("session-1"); + expect(execCall.defaults.sessionStore).toBe("/tmp/openclaw-sessions.json"); expect(execCall.defaults.currentChannelId).toBe("bot"); expect(execCall.defaults.accountId).toBe("account-1"); expect(execCall.params.security).toBe("allowlist"); diff --git a/src/auto-reply/reply/commands-export-trajectory.ts b/src/auto-reply/reply/commands-export-trajectory.ts index 17dd6eccaa4b..35ed9aec4067 100644 --- a/src/auto-reply/reply/commands-export-trajectory.ts +++ b/src/auto-reply/reply/commands-export-trajectory.ts @@ -244,10 +244,13 @@ async function requestTrajectoryExportApproval( trigger: "export-trajectory", scopeKey: EXPORT_TRAJECTORY_EXEC_SCOPE_KEY, allowBackground: true, + approvalFollowupMode: "agent", timeoutSec, cwd: params.workspaceDir, agentId, sessionKey: params.sessionKey, + sessionId: params.sessionEntry?.sessionId, + sessionStore: params.cfg.session?.store, mainKey: params.cfg.session?.mainKey, sessionScope: params.cfg.session?.scope, messageProvider: options.privateApprovalTarget?.channel ?? params.command.channel, diff --git a/src/gateway/gateway-trajectory-export.live.test.ts b/src/gateway/gateway-trajectory-export.live.test.ts index fa9cb3669f7d..3daec4ff3862 100644 --- a/src/gateway/gateway-trajectory-export.live.test.ts +++ b/src/gateway/gateway-trajectory-export.live.test.ts @@ -229,10 +229,24 @@ function getTrajectoryExportApprovalCommands(entry: TrajectoryExportApprovalEntr ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); } +function hasTrajectoryExportArgv(argv: string[] | undefined): boolean { + if (!argv) { + return false; + } + return argv.some((arg, index) => arg === "sessions" && argv[index + 1] === "export-trajectory"); +} + function isTrajectoryExportApproval(entry: TrajectoryExportApprovalEntry): boolean { - return getTrajectoryExportApprovalCommands(entry).some((command) => - command.includes("sessions export-trajectory"), - ); + if ( + hasTrajectoryExportArgv(entry.request?.commandArgv) || + hasTrajectoryExportArgv(entry.commandArgv) + ) { + return true; + } + return getTrajectoryExportApprovalCommands(entry).some((command) => { + const normalized = command.replaceAll(/['"]/gu, ""); + return normalized.includes("sessions export-trajectory"); + }); } function summarizeTrajectoryExportApproval( @@ -401,9 +415,7 @@ async function approveTrajectoryExport( `expected trajectory export approval id; approvals=${JSON.stringify(lastApprovalSummaries)}`, ); } - expect(getTrajectoryExportApprovalCommands(approval).join("\n")).toContain( - "sessions export-trajectory", - ); + expect(isTrajectoryExportApproval(approval)).toBe(true); await client.request( "exec.approval.resolve", { id: approval.id, decision: "allow-once" },