Skip to content

Commit 63da2c7

Browse files
Nanako0129steipete
authored andcommitted
fix(exec): resume agent session after approval completion
1 parent 4fa1163 commit 63da2c7

3 files changed

Lines changed: 131 additions & 52 deletions

File tree

src/agents/bash-tools.exec-approval-followup.test.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ describe("exec approval followup", () => {
3636
expect(prompt).not.toContain("already approved has completed");
3737
});
3838

39+
it("tells the agent to continue the task before replying when the command succeeds", () => {
40+
const prompt = buildExecApprovalFollowupPrompt("Exec finished (gateway id=req-1, code 0)\nok");
41+
42+
expect(prompt).toContain("continue from this result before replying to the user");
43+
expect(prompt).toContain("Continue the task if needed, then reply to the user");
44+
});
45+
3946
it("keeps followups internal when no external route is available", async () => {
4047
await sendExecApprovalFollowup({
4148
approvalId: "req-1",
@@ -79,7 +86,7 @@ describe("exec approval followup", () => {
7986
accountId: "default",
8087
threadId: "789",
8188
},
82-
])("uses direct external delivery for $channel followups", async (target) => {
89+
])("uses agent continuation for $channel followups when a session exists", async (target) => {
8390
await sendExecApprovalFollowup({
8491
approvalId: `req-${target.channel}`,
8592
sessionKey: target.sessionKey,
@@ -90,17 +97,42 @@ describe("exec approval followup", () => {
9097
resultText: "slack exec approval smoke",
9198
});
9299

93-
expect(sendMessage).toHaveBeenCalledWith(
100+
expect(callGatewayTool).toHaveBeenCalledWith(
101+
"agent",
102+
expect.any(Object),
94103
expect.objectContaining({
104+
sessionKey: target.sessionKey,
105+
deliver: true,
106+
bestEffortDeliver: true,
95107
channel: target.channel,
96108
to: target.to,
97109
accountId: target.accountId,
98110
threadId: target.threadId,
99-
content: "slack exec approval smoke",
100-
mirror: expect.objectContaining({
101-
sessionKey: target.sessionKey,
102-
idempotencyKey: `exec-approval-followup:req-${target.channel}`,
103-
}),
111+
idempotencyKey: `exec-approval-followup:req-${target.channel}`,
112+
}),
113+
{ expectFinal: true },
114+
);
115+
expect(sendMessage).not.toHaveBeenCalled();
116+
});
117+
118+
it("falls back to direct external delivery only when no session exists", async () => {
119+
await sendExecApprovalFollowup({
120+
approvalId: "req-no-session",
121+
turnSourceChannel: "discord",
122+
turnSourceTo: "123",
123+
turnSourceAccountId: "default",
124+
turnSourceThreadId: "456",
125+
resultText: "discord exec approval smoke",
126+
});
127+
128+
expect(sendMessage).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
channel: "discord",
131+
to: "123",
132+
accountId: "default",
133+
threadId: "456",
134+
content: "discord exec approval smoke",
135+
idempotencyKey: "exec-approval-followup:req-no-session",
104136
}),
105137
);
106138
expect(callGatewayTool).not.toHaveBeenCalled();

src/agents/bash-tools.exec-approval-followup.ts

Lines changed: 37 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { resolveExternalBestEffortDeliveryTarget } from "../infra/outbound/best-effort-delivery.js";
22
import { sendMessage } from "../infra/outbound/message.js";
3-
import { parseAgentSessionKey } from "../routing/session-key.js";
43
import { isGatewayMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
54
import { callGatewayTool } from "./tools/gateway.js";
65

@@ -38,11 +37,13 @@ export function buildExecApprovalFollowupPrompt(resultText: string): string {
3837
return [
3938
"An async command the user already approved has completed.",
4039
"Do not run the command again.",
40+
"If the task requires more steps, continue from this result before replying to the user.",
41+
"Only ask the user for help if you are actually blocked.",
4142
"",
4243
"Exact completion details:",
4344
trimmed,
4445
"",
45-
"Reply to the user in a helpful way.",
46+
"Continue the task if needed, then reply to the user in a helpful way.",
4647
"If it succeeded, share the relevant output.",
4748
"If it failed, explain what went wrong.",
4849
].join("\n");
@@ -69,59 +70,50 @@ export async function sendExecApprovalFollowup(
6970
? normalizedTurnSourceChannel
7071
: undefined;
7172

73+
if (sessionKey) {
74+
await callGatewayTool(
75+
"agent",
76+
{ timeoutMs: 60_000 },
77+
{
78+
sessionKey,
79+
message: buildExecApprovalFollowupPrompt(resultText),
80+
deliver: deliveryTarget.deliver,
81+
...(deliveryTarget.deliver ? { bestEffortDeliver: true as const } : {}),
82+
channel: deliveryTarget.deliver ? deliveryTarget.channel : sessionOnlyOriginChannel,
83+
to: deliveryTarget.deliver
84+
? deliveryTarget.to
85+
: sessionOnlyOriginChannel
86+
? params.turnSourceTo
87+
: undefined,
88+
accountId: deliveryTarget.deliver
89+
? deliveryTarget.accountId
90+
: sessionOnlyOriginChannel
91+
? params.turnSourceAccountId
92+
: undefined,
93+
threadId: deliveryTarget.deliver
94+
? deliveryTarget.threadId
95+
: sessionOnlyOriginChannel
96+
? params.turnSourceThreadId
97+
: undefined,
98+
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
99+
},
100+
{ expectFinal: true },
101+
);
102+
return true;
103+
}
104+
72105
if (deliveryTarget.deliver) {
73-
const requesterAgentId = sessionKey ? parseAgentSessionKey(sessionKey)?.agentId : undefined;
74106
await sendMessage({
75107
channel: deliveryTarget.channel,
76108
to: deliveryTarget.to ?? "",
77109
accountId: deliveryTarget.accountId,
78110
threadId: deliveryTarget.threadId,
79111
content: resultText,
80-
agentId: requesterAgentId,
112+
agentId: undefined,
81113
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
82-
mirror: sessionKey
83-
? {
84-
sessionKey,
85-
agentId: requesterAgentId,
86-
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
87-
}
88-
: undefined,
89114
});
90115
return true;
91116
}
92117

93-
if (!sessionKey) {
94-
throw new Error("Session key or deliverable origin route is required");
95-
}
96-
97-
await callGatewayTool(
98-
"agent",
99-
{ timeoutMs: 60_000 },
100-
{
101-
sessionKey,
102-
message: buildExecApprovalFollowupPrompt(resultText),
103-
deliver: deliveryTarget.deliver,
104-
...(deliveryTarget.deliver ? { bestEffortDeliver: true as const } : {}),
105-
channel: deliveryTarget.deliver ? deliveryTarget.channel : sessionOnlyOriginChannel,
106-
to: deliveryTarget.deliver
107-
? deliveryTarget.to
108-
: sessionOnlyOriginChannel
109-
? params.turnSourceTo
110-
: undefined,
111-
accountId: deliveryTarget.deliver
112-
? deliveryTarget.accountId
113-
: sessionOnlyOriginChannel
114-
? params.turnSourceAccountId
115-
: undefined,
116-
threadId: deliveryTarget.deliver
117-
? deliveryTarget.threadId
118-
: sessionOnlyOriginChannel
119-
? params.turnSourceThreadId
120-
: undefined,
121-
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
122-
},
123-
{ expectFinal: true },
124-
);
125-
126-
return true;
118+
throw new Error("Session key or deliverable origin route is required");
127119
}

src/agents/bash-tools.exec.approval-id.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,23 @@ vi.mock("../infra/exec-obfuscation-detect.js", () => ({
2525
})),
2626
}));
2727

28+
vi.mock("../infra/outbound/message.js", () => ({
29+
sendMessage: vi.fn(async () => ({ ok: true })),
30+
}));
31+
2832
let callGatewayTool: typeof import("./tools/gateway.js").callGatewayTool;
2933
let createExecTool: typeof import("./bash-tools.exec.js").createExecTool;
3034
let detectCommandObfuscation: typeof import("../infra/exec-obfuscation-detect.js").detectCommandObfuscation;
3135
let getExecApprovalApproverDmNoticeText: typeof import("../infra/exec-approval-reply.js").getExecApprovalApproverDmNoticeText;
36+
let sendMessage: typeof import("../infra/outbound/message.js").sendMessage;
3237

3338
async function loadExecApprovalModules() {
3439
vi.resetModules();
3540
({ callGatewayTool } = await import("./tools/gateway.js"));
3641
({ createExecTool } = await import("./bash-tools.exec.js"));
3742
({ detectCommandObfuscation } = await import("../infra/exec-obfuscation-detect.js"));
3843
({ getExecApprovalApproverDmNoticeText } = await import("../infra/exec-approval-reply.js"));
44+
({ sendMessage } = await import("../infra/outbound/message.js"));
3945
}
4046

4147
function buildPreparedSystemRunPayload(rawInvokeParams: unknown) {
@@ -472,6 +478,55 @@ describe("exec approvals", () => {
472478
);
473479
});
474480

481+
it("continues the original agent session after approved gateway exec completes with an external route", async () => {
482+
const agentCalls: Array<Record<string, unknown>> = [];
483+
484+
mockAcceptedApprovalFlow({
485+
onAgent: (params) => {
486+
agentCalls.push(params);
487+
},
488+
});
489+
490+
const tool = createExecTool({
491+
host: "gateway",
492+
ask: "always",
493+
approvalRunningNoticeMs: 0,
494+
sessionKey: "agent:main:discord:channel:123",
495+
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
496+
messageProvider: "discord",
497+
currentChannelId: "123",
498+
accountId: "default",
499+
currentThreadTs: "456",
500+
});
501+
502+
const result = await tool.execute("call-gw-followup-discord", {
503+
command: "echo ok",
504+
workdir: process.cwd(),
505+
gatewayUrl: undefined,
506+
gatewayToken: undefined,
507+
});
508+
509+
expect(result.details.status).toBe("approval-pending");
510+
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 20 }).toBe(1);
511+
expect(agentCalls[0]).toEqual(
512+
expect.objectContaining({
513+
sessionKey: "agent:main:discord:channel:123",
514+
deliver: true,
515+
bestEffortDeliver: true,
516+
channel: "discord",
517+
to: "123",
518+
accountId: "default",
519+
threadId: "456",
520+
idempotencyKey: expect.stringContaining("exec-approval-followup:"),
521+
}),
522+
);
523+
expect(typeof agentCalls[0]?.message).toBe("string");
524+
expect(agentCalls[0]?.message).toContain(
525+
"If the task requires more steps, continue from this result before replying to the user.",
526+
);
527+
expect(sendMessage).not.toHaveBeenCalled();
528+
});
529+
475530
it("executes approved commands and emits a session-only followup in webchat-only mode", async () => {
476531
const agentCalls: Array<Record<string, unknown>> = [];
477532
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-followup-sidefx-"));

0 commit comments

Comments
 (0)