mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-19 02:24:46 +00:00
fix(gateway): dedupe exec followup continuations (#82717)
Co-authored-by: Miya <miya@Miyas-Mac-mini.local>
This commit is contained in:
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/config: show concise human config-write output with an indented backup path instead of printing checksum-heavy overwrite audit details by default.
|
||||
- CLI/docs: call the canonical lowercase docs MCP search tool and surface MCP errors instead of returning empty search results. Fixes #82702. (#82704) Thanks @hclsys.
|
||||
- QA-Lab: ignore heartbeat-only operational transcripts when capturing runtime parity cells so background checks cannot replace the scenario reply. (#80323) Thanks @100yenadmin.
|
||||
- Gateway/exec approvals: wait for accepted async approval follow-up runs instead of direct-fallback sending duplicate completions when retries use different nonce keys. Fixes #82711. (#82717) Thanks @udaymanish6.
|
||||
- CLI/config: add `--dry-run` support to `openclaw config unset`, with `--json` output and allow-exec validation parity with `config set`/`config patch` dry-run handling. (#81895) Thanks @giodl73-repo.
|
||||
- Memory-core: retry disabled dreaming cron cleanup until cron is available after startup, so persisted managed dreaming jobs are removed after restart. Fixes #82383. (#82389) Thanks @neeravmakwana.
|
||||
- Providers/xAI: keep retired Grok 3, Grok 4 Fast, Grok 4.1 Fast, and Grok Code slugs out of model pickers while preserving compatibility resolution for existing configs.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(async () => ({ ok: true })),
|
||||
callGatewayTool: vi.fn(async () => ({ status: "ok" })),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/outbound/message.js", () => ({
|
||||
@@ -42,7 +42,22 @@ function expectGatewayAgentFollowup(expected: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(params[key]).toBe(value);
|
||||
}
|
||||
expect(call[3]).toEqual({ expectFinal: true });
|
||||
expect(call[3]).toBeUndefined();
|
||||
return params;
|
||||
}
|
||||
|
||||
function expectGatewayAgentWait(expected: Record<string, unknown>) {
|
||||
const call = (callGatewayTool as { mock?: { calls?: unknown[][] } }).mock?.calls?.[1];
|
||||
if (!call) {
|
||||
throw new Error("expected agent.wait call");
|
||||
}
|
||||
expect(call[0]).toBe("agent.wait");
|
||||
requireRecord(call[1], "gateway wait context");
|
||||
const params = requireRecord(call[2], "gateway wait params");
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(params[key]).toBe(value);
|
||||
}
|
||||
expect(call[3]).toBeUndefined();
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -134,6 +149,41 @@ describe("exec approval followup", () => {
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for accepted agent followups without direct fallback", async () => {
|
||||
vi.mocked(callGatewayTool)
|
||||
.mockResolvedValueOnce({
|
||||
runId: "exec-approval-followup:req-wait:nonce:nonce-wait",
|
||||
status: "accepted",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
runId: "exec-approval-followup:req-wait:nonce:nonce-wait",
|
||||
status: "ok",
|
||||
});
|
||||
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId: "req-wait",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "123",
|
||||
turnSourceAccountId: "default",
|
||||
resultText: "Exec finished (gateway id=req-wait, session=sess_1, code 0)\nall good",
|
||||
idempotencyKey: "exec-approval-followup:req-wait:nonce:nonce-wait",
|
||||
});
|
||||
|
||||
expectGatewayAgentFollowup({
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
idempotencyKey: "exec-approval-followup:req-wait:nonce:nonce-wait",
|
||||
});
|
||||
expectGatewayAgentWait({
|
||||
runId: "exec-approval-followup:req-wait:nonce:nonce-wait",
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to sanitized direct external delivery only when no session exists", async () => {
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId: "req-no-session",
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
} from "../infra/outbound/best-effort-delivery.js";
|
||||
import { sendMessage } from "../infra/outbound/message.js";
|
||||
import { isCronSessionKey, isSubagentSessionKey } from "../sessions/session-key-utils.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import { isGatewayMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import { buildExecApprovalFollowupIdempotencyKey } from "./bash-tools.exec-approval-followup-state.js";
|
||||
import {
|
||||
@@ -124,6 +127,51 @@ function buildSessionResumeFallbackPrefix(): string {
|
||||
return "Automatic session resume failed, so sending the status directly.\n\n";
|
||||
}
|
||||
|
||||
function readGatewayStatus(value: unknown): string | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? normalizeOptionalString((value as { status?: unknown }).status)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readGatewayRunId(value: unknown): string | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? normalizeOptionalString((value as { runId?: unknown }).runId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function buildFollowupWaitError(params: { status?: string; error?: unknown }): Error {
|
||||
const suffix =
|
||||
typeof params.error === "string" && params.error.trim()
|
||||
? `: ${params.error.trim()}`
|
||||
: params.status
|
||||
? `: ${params.status}`
|
||||
: "";
|
||||
return new Error(`exec approval followup session resume failed${suffix}`);
|
||||
}
|
||||
|
||||
function isSuccessfulFollowupStatus(status: string | undefined): boolean {
|
||||
return status === "ok";
|
||||
}
|
||||
|
||||
async function waitForAgentFollowupRun(params: {
|
||||
runId: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
const wait = await callGatewayTool(
|
||||
"agent.wait",
|
||||
{ timeoutMs: params.timeoutMs + 2_000 },
|
||||
{
|
||||
runId: params.runId,
|
||||
timeoutMs: params.timeoutMs,
|
||||
},
|
||||
);
|
||||
const status = readGatewayStatus(wait);
|
||||
if (isSuccessfulFollowupStatus(status)) {
|
||||
return;
|
||||
}
|
||||
throw buildFollowupWaitError({ status, error: wait.error });
|
||||
}
|
||||
|
||||
function shouldPrefixDirectFollowupWithSessionResumeFailure(params: {
|
||||
resultText: string;
|
||||
sessionError: unknown;
|
||||
@@ -249,25 +297,34 @@ export async function sendExecApprovalFollowup(
|
||||
|
||||
if (sessionKey && params.direct !== true) {
|
||||
try {
|
||||
await callGatewayTool(
|
||||
"agent",
|
||||
{ timeoutMs: 60_000 },
|
||||
buildAgentFollowupArgs({
|
||||
approvalId: params.approvalId,
|
||||
sessionKey,
|
||||
resultText,
|
||||
deliveryTarget,
|
||||
sessionOnlyOriginChannel,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
internalRuntimeHandoffId: params.internalRuntimeHandoffId,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
}),
|
||||
{ expectFinal: true },
|
||||
);
|
||||
return true;
|
||||
const agentArgs = buildAgentFollowupArgs({
|
||||
approvalId: params.approvalId,
|
||||
sessionKey,
|
||||
resultText,
|
||||
deliveryTarget,
|
||||
sessionOnlyOriginChannel,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
internalRuntimeHandoffId: params.internalRuntimeHandoffId,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
});
|
||||
const accepted = await callGatewayTool("agent", { timeoutMs: 60_000 }, agentArgs);
|
||||
const status = readGatewayStatus(accepted);
|
||||
if (isSuccessfulFollowupStatus(status)) {
|
||||
return true;
|
||||
}
|
||||
if (status === "accepted" || status === "in_flight" || status === "pending") {
|
||||
const runId =
|
||||
readGatewayRunId(accepted) ?? normalizeOptionalString(agentArgs.idempotencyKey);
|
||||
if (!runId) {
|
||||
throw buildFollowupWaitError({ status: "missing-run-id" });
|
||||
}
|
||||
await waitForAgentFollowupRun({ runId, timeoutMs: 60_000 });
|
||||
return true;
|
||||
}
|
||||
throw buildFollowupWaitError({ status, error: accepted.error });
|
||||
} catch (err) {
|
||||
sessionError = err;
|
||||
}
|
||||
|
||||
@@ -306,6 +306,35 @@ describe("startGatewayMaintenanceTimers", () => {
|
||||
stopMaintenanceTimers(timers);
|
||||
});
|
||||
|
||||
it("keeps active exec approval dedupe aliases past the normal ttl", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-22T00:00:00Z"));
|
||||
const { startGatewayMaintenanceTimers } = await import("./server-maintenance.js");
|
||||
const deps = createMaintenanceTimerDeps();
|
||||
const now = Date.now();
|
||||
const runId = "exec-approval-followup:req-active:nonce:retry-1";
|
||||
deps.chatAbortControllers.set(runId, createActiveRun("agent:main:main", "agent"));
|
||||
deps.dedupe.set("agent:exec-approval-followup:req-active", {
|
||||
ts: now - DEDUPE_TTL_MS - 1,
|
||||
ok: true,
|
||||
payload: { runId, status: "accepted" },
|
||||
});
|
||||
deps.dedupe.set("agent:exec-approval-followup:req-stale", {
|
||||
ts: now - DEDUPE_TTL_MS - 1,
|
||||
ok: true,
|
||||
payload: { runId: "exec-approval-followup:req-stale:nonce:retry-1", status: "accepted" },
|
||||
});
|
||||
|
||||
const timers = startGatewayMaintenanceTimers(deps);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(deps.dedupe.has("agent:exec-approval-followup:req-active")).toBe(true);
|
||||
expect(deps.dedupe.has("agent:exec-approval-followup:req-stale")).toBe(false);
|
||||
|
||||
stopMaintenanceTimers(timers);
|
||||
});
|
||||
|
||||
it("evicts dedupe overflow by oldest timestamp even after reinsertion", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-22T00:00:00Z"));
|
||||
|
||||
@@ -88,11 +88,29 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
const dedupeCleanup = setInterval(() => {
|
||||
const AGENT_RUN_SEQ_MAX = 10_000;
|
||||
const now = Date.now();
|
||||
const isActiveRunDedupeKey = (key: string) => {
|
||||
const resolveDedupeRunId = (key: string, entry: DedupeEntry) => {
|
||||
if (!key.startsWith("agent:") && !key.startsWith("chat:")) {
|
||||
return undefined;
|
||||
}
|
||||
const keyRunId = key.slice(key.indexOf(":") + 1);
|
||||
if (keyRunId) {
|
||||
const directEntry = params.chatAbortControllers.get(keyRunId);
|
||||
if (directEntry) {
|
||||
return keyRunId;
|
||||
}
|
||||
}
|
||||
const payload = entry.payload;
|
||||
return payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? typeof (payload as { runId?: unknown }).runId === "string"
|
||||
? (payload as { runId: string }).runId.trim() || undefined
|
||||
: undefined
|
||||
: undefined;
|
||||
};
|
||||
const isActiveRunDedupeKey = (key: string, dedupeEntry: DedupeEntry) => {
|
||||
if (!key.startsWith("agent:") && !key.startsWith("chat:")) {
|
||||
return false;
|
||||
}
|
||||
const runId = key.slice(key.indexOf(":") + 1);
|
||||
const runId = resolveDedupeRunId(key, dedupeEntry);
|
||||
const entry = runId ? params.chatAbortControllers.get(runId) : undefined;
|
||||
if (!entry) {
|
||||
return false;
|
||||
@@ -100,7 +118,7 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
return key.startsWith("agent:") ? entry.kind === "agent" : entry.kind !== "agent";
|
||||
};
|
||||
for (const [k, v] of params.dedupe) {
|
||||
if (isActiveRunDedupeKey(k)) {
|
||||
if (isActiveRunDedupeKey(k, v)) {
|
||||
continue;
|
||||
}
|
||||
if (now - v.ts > DEDUPE_TTL_MS) {
|
||||
@@ -110,7 +128,7 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
if (params.dedupe.size > DEDUPE_MAX) {
|
||||
const excess = params.dedupe.size - DEDUPE_MAX;
|
||||
const oldestKeys = [...params.dedupe.entries()]
|
||||
.filter(([key]) => !isActiveRunDedupeKey(key))
|
||||
.filter(([key, entry]) => !isActiveRunDedupeKey(key, entry))
|
||||
.toSorted(([, left], [, right]) => left.ts - right.ts)
|
||||
.slice(0, excess)
|
||||
.map(([key]) => key);
|
||||
|
||||
@@ -1696,6 +1696,239 @@ describe("gateway agent handler", () => {
|
||||
expect(callArgs.bashElevated).toEqual(bashElevated);
|
||||
});
|
||||
|
||||
it("dedupes elevated exec approval followups across nonce idempotency keys", async () => {
|
||||
const bashElevated = {
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "on" as const,
|
||||
};
|
||||
const firstRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-duplicate",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
const secondRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-duplicate",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
if (!firstRegistration || !secondRegistration) {
|
||||
throw new Error("expected runtime handoff ids");
|
||||
}
|
||||
mockMainSessionEntry({
|
||||
sessionId: "existing-session-id",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
mocks.agentCommand.mockImplementation(() => new Promise(() => {}));
|
||||
const context = makeContext();
|
||||
const agentCommandCallsBefore = mocks.agentCommand.mock.calls.length;
|
||||
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "exec followup",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: firstRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: firstRegistration.handoffId,
|
||||
},
|
||||
{ reqId: "exec-followup-duplicate-1", client: backendGatewayClient(), context },
|
||||
);
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore + 1);
|
||||
|
||||
const secondRespond = await invokeAgent(
|
||||
{
|
||||
message: "exec followup duplicate",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: secondRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: secondRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-duplicate-2",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
);
|
||||
await flushScheduledDispatchStep();
|
||||
await flushScheduledDispatchStep();
|
||||
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore + 1);
|
||||
expect(mockCallArg(secondRespond, 0, 3)).toEqual({ cached: true });
|
||||
});
|
||||
|
||||
it("reserves exec approval followup dedupe before awaited session work", async () => {
|
||||
const bashElevated = {
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "on" as const,
|
||||
};
|
||||
const firstRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-overlap",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
const secondRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-overlap",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
if (!firstRegistration || !secondRegistration) {
|
||||
throw new Error("expected runtime handoff ids");
|
||||
}
|
||||
mockMainSessionEntry({
|
||||
sessionId: "existing-session-id",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
let releaseFirstSessionWrite: (() => void) | undefined;
|
||||
let sessionWriteCalls = 0;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
sessionWriteCalls += 1;
|
||||
if (sessionWriteCalls === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstSessionWrite = resolve;
|
||||
});
|
||||
}
|
||||
const store = {
|
||||
"agent:main:main": buildExistingMainStoreEntry({
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
}),
|
||||
};
|
||||
return await updater(store);
|
||||
});
|
||||
mocks.agentCommand.mockImplementation(() => new Promise(() => {}));
|
||||
const context = makeContext();
|
||||
const agentCommandCallsBefore = mocks.agentCommand.mock.calls.length;
|
||||
|
||||
const first = invokeAgent(
|
||||
{
|
||||
message: "exec followup",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: firstRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: firstRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-overlap-1",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
);
|
||||
await waitForAssertion(() => expect(sessionWriteCalls).toBe(1));
|
||||
|
||||
const secondRespond = await invokeAgent(
|
||||
{
|
||||
message: "exec followup duplicate",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: secondRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: secondRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-overlap-2",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore);
|
||||
expect(sessionWriteCalls).toBe(1);
|
||||
expect(mockCallArg(secondRespond, 0, 1)).toMatchObject({
|
||||
runId: firstRegistration.idempotencyKey,
|
||||
status: "accepted",
|
||||
});
|
||||
expect(mockCallArg(secondRespond, 0, 3)).toEqual({ cached: true });
|
||||
|
||||
releaseFirstSessionWrite?.();
|
||||
await first;
|
||||
await flushScheduledDispatchStep();
|
||||
await flushScheduledDispatchStep();
|
||||
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore + 1);
|
||||
});
|
||||
|
||||
it("clears reserved exec approval dedupe when pre-run session work fails", async () => {
|
||||
const bashElevated = {
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "on" as const,
|
||||
};
|
||||
const firstRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-pre-run-fail",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
const secondRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-pre-run-fail",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
if (!firstRegistration || !secondRegistration) {
|
||||
throw new Error("expected runtime handoff ids");
|
||||
}
|
||||
mockMainSessionEntry({
|
||||
sessionId: "existing-session-id",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
const context = makeContext();
|
||||
const agentCommandCallsBefore = mocks.agentCommand.mock.calls.length;
|
||||
mocks.updateSessionStore.mockRejectedValueOnce(new Error("session write failed"));
|
||||
|
||||
await expect(
|
||||
invokeAgent(
|
||||
{
|
||||
message: "exec followup",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: firstRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: firstRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-pre-run-fail-1",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("session write failed");
|
||||
|
||||
expect(context.dedupe.get(`agent:${firstRegistration.idempotencyKey}`)).toBeUndefined();
|
||||
expect(
|
||||
context.dedupe.get("agent:exec-approval-followup:req-elevated-pre-run-fail"),
|
||||
).toBeUndefined();
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore);
|
||||
|
||||
const secondRespond = await invokeAgent(
|
||||
{
|
||||
message: "exec followup retry",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: secondRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: secondRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-pre-run-fail-2",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockCallArg(secondRespond, 0, 1)).toMatchObject({
|
||||
runId: secondRegistration.idempotencyKey,
|
||||
status: "accepted",
|
||||
});
|
||||
await flushScheduledDispatchStep();
|
||||
await flushScheduledDispatchStep();
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore + 1);
|
||||
});
|
||||
|
||||
it("does not consume exec approval runtime handoffs from non-backend callers", async () => {
|
||||
const bashElevated = {
|
||||
enabled: true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user