mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 13:30:20 +00:00
241 lines
7.9 KiB
TypeScript
241 lines
7.9 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("./tools/gateway.js", () => ({
|
|
callGatewayTool: vi.fn(async () => ({ ok: true })),
|
|
}));
|
|
|
|
vi.mock("../infra/outbound/message.js", () => ({
|
|
sendMessage: vi.fn(async () => ({ ok: true })),
|
|
}));
|
|
|
|
import { sendMessage } from "../infra/outbound/message.js";
|
|
import {
|
|
buildExecApprovalFollowupPrompt,
|
|
sendExecApprovalFollowup,
|
|
} from "./bash-tools.exec-approval-followup.js";
|
|
import { callGatewayTool } from "./tools/gateway.js";
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe("exec approval followup", () => {
|
|
it("uses an explicit denial prompt when the command did not run", () => {
|
|
const prompt = buildExecApprovalFollowupPrompt(
|
|
"Exec denied (gateway id=req-1, user-denied): uname -a",
|
|
);
|
|
|
|
expect(prompt).toContain("did not run");
|
|
expect(prompt).toContain("Do not mention, summarize, or reuse output");
|
|
expect(prompt).not.toContain("already approved has completed");
|
|
});
|
|
|
|
it("tells the agent to continue the task before replying when the command succeeds", () => {
|
|
const prompt = buildExecApprovalFollowupPrompt("Exec finished (gateway id=req-1, code 0)\nok");
|
|
|
|
expect(prompt).toContain("continue from this result before replying to the user");
|
|
expect(prompt).toContain("Continue the task if needed, then reply to the user");
|
|
});
|
|
|
|
it("keeps followups internal when no external route is available", async () => {
|
|
await sendExecApprovalFollowup({
|
|
approvalId: "req-1",
|
|
sessionKey: "agent:main:main",
|
|
resultText: "Exec completed: echo ok",
|
|
});
|
|
|
|
expect(callGatewayTool).toHaveBeenCalledWith(
|
|
"agent",
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
sessionKey: "agent:main:main",
|
|
deliver: false,
|
|
channel: undefined,
|
|
to: undefined,
|
|
}),
|
|
{ expectFinal: true },
|
|
);
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
channel: "slack",
|
|
sessionKey: "agent:main:slack:channel:C123",
|
|
to: "channel:C123",
|
|
accountId: "default",
|
|
threadId: "1712419200.1234",
|
|
},
|
|
{
|
|
channel: "discord",
|
|
sessionKey: "agent:main:discord:channel:123",
|
|
to: "123",
|
|
accountId: "default",
|
|
threadId: "456",
|
|
},
|
|
{
|
|
channel: "telegram",
|
|
sessionKey: "agent:main:telegram:-100123",
|
|
to: "-100123",
|
|
accountId: "default",
|
|
threadId: "789",
|
|
},
|
|
])("uses agent continuation for $channel followups when a session exists", async (target) => {
|
|
await sendExecApprovalFollowup({
|
|
approvalId: `req-${target.channel}`,
|
|
sessionKey: target.sessionKey,
|
|
turnSourceChannel: target.channel,
|
|
turnSourceTo: target.to,
|
|
turnSourceAccountId: target.accountId,
|
|
turnSourceThreadId: target.threadId,
|
|
resultText: "slack exec approval smoke",
|
|
});
|
|
|
|
expect(callGatewayTool).toHaveBeenCalledWith(
|
|
"agent",
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
sessionKey: target.sessionKey,
|
|
deliver: true,
|
|
bestEffortDeliver: true,
|
|
channel: target.channel,
|
|
to: target.to,
|
|
accountId: target.accountId,
|
|
threadId: target.threadId,
|
|
idempotencyKey: `exec-approval-followup:req-${target.channel}`,
|
|
}),
|
|
{ expectFinal: true },
|
|
);
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to sanitized direct external delivery only when no session exists", async () => {
|
|
await sendExecApprovalFollowup({
|
|
approvalId: "req-no-session",
|
|
turnSourceChannel: "discord",
|
|
turnSourceTo: "123",
|
|
turnSourceAccountId: "default",
|
|
turnSourceThreadId: "456",
|
|
resultText: "Exec finished (gateway id=req-no-session, session=sess_1, code 0)\nall good",
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "discord",
|
|
to: "123",
|
|
accountId: "default",
|
|
threadId: "456",
|
|
content: "all good",
|
|
idempotencyKey: "exec-approval-followup:req-no-session",
|
|
}),
|
|
);
|
|
expect(callGatewayTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to sanitized direct delivery when session resume fails", async () => {
|
|
vi.mocked(callGatewayTool).mockRejectedValueOnce(new Error("session missing"));
|
|
|
|
await sendExecApprovalFollowup({
|
|
approvalId: "req-session-resume-failed",
|
|
sessionKey: "agent:main:discord:channel:123",
|
|
turnSourceChannel: "discord",
|
|
turnSourceTo: "123",
|
|
turnSourceAccountId: "default",
|
|
turnSourceThreadId: "456",
|
|
resultText:
|
|
"Exec finished (gateway id=req-session-resume-failed, session=sess_1, code 0)\nall good",
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
content: "Automatic session resume failed, so sending the status directly.\n\nall good",
|
|
idempotencyKey: "exec-approval-followup:req-session-resume-failed",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses a generic summary when a no-session completion has no user-visible output", async () => {
|
|
await sendExecApprovalFollowup({
|
|
approvalId: "req-no-session-empty",
|
|
turnSourceChannel: "discord",
|
|
turnSourceTo: "123",
|
|
turnSourceAccountId: "default",
|
|
turnSourceThreadId: "456",
|
|
resultText: "Exec finished (gateway id=req-no-session-empty, session=sess_2, code 0)",
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
content: "Background command finished.",
|
|
idempotencyKey: "exec-approval-followup:req-no-session-empty",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses safe denied copy when session resume fails", async () => {
|
|
vi.mocked(callGatewayTool).mockRejectedValueOnce(new Error("session missing"));
|
|
|
|
await sendExecApprovalFollowup({
|
|
approvalId: "req-denied-resume-failed",
|
|
sessionKey: "agent:main:telegram:-100123",
|
|
turnSourceChannel: "telegram",
|
|
turnSourceTo: "-100123",
|
|
turnSourceAccountId: "default",
|
|
turnSourceThreadId: "789",
|
|
resultText: "Exec denied (gateway id=req-denied-resume-failed, approval-timeout): uname -a",
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
content:
|
|
"Automatic session resume failed, so sending the status directly.\n\nCommand did not run: approval timed out.",
|
|
idempotencyKey: "exec-approval-followup:req-denied-resume-failed",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("suppresses denied followups for subagent sessions", async () => {
|
|
await expect(
|
|
sendExecApprovalFollowup({
|
|
approvalId: "req-denied-subagent",
|
|
sessionKey: "agent:main:subagent:test",
|
|
turnSourceChannel: "telegram",
|
|
turnSourceTo: "123",
|
|
turnSourceAccountId: "default",
|
|
resultText: "Exec denied (gateway id=req-denied-subagent, approval-timeout): uname -a",
|
|
}),
|
|
).resolves.toBe(false);
|
|
|
|
expect(callGatewayTool).not.toHaveBeenCalled();
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
"Exec denied (gateway id=req-denied-nosession, approval-timeout): uname -a",
|
|
"exec denied (gateway id=req-denied-nosession, approval-timeout): uname -a",
|
|
])("does not mirror raw denied followups without a session: %s", async (resultText) => {
|
|
await expect(
|
|
sendExecApprovalFollowup({
|
|
approvalId: "req-denied-nosession",
|
|
turnSourceChannel: "telegram",
|
|
turnSourceTo: "123",
|
|
turnSourceAccountId: "default",
|
|
resultText,
|
|
}),
|
|
).resolves.toBe(false);
|
|
|
|
expect(callGatewayTool).not.toHaveBeenCalled();
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when neither a session nor a deliverable route is available", async () => {
|
|
await expect(
|
|
sendExecApprovalFollowup({
|
|
approvalId: "req-missing",
|
|
turnSourceChannel: "slack",
|
|
resultText: "Exec completed: echo ok",
|
|
}),
|
|
).rejects.toThrow("Session key or deliverable origin route is required");
|
|
});
|
|
});
|