fix(acp): avoid inline delivery for oneshot run spawns (#39014)

* fix(acp): scope inline delivery to session spawns

* test(acp): cover run and session delivery behavior

* Changelog: add ACP run delivery bootstrap fix

---------

Co-authored-by: 徐善 <samxu633@gmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
lidamao633
2026-03-08 09:37:22 +08:00
committed by GitHub
parent 5b30c9d3d7
commit 01833c5111
3 changed files with 63 additions and 6 deletions

View File

@@ -327,6 +327,7 @@ Docs: https://docs.openclaw.ai
- Telegram/preview-final edit idempotence: treat `message is not modified` errors during preview finalization as delivered so partial-stream final replies do not fall back to duplicate sends. Landed from contributor PR #34983 by @HOYALIM. Thanks @HOYALIM.
- Telegram/DM streaming transport parity: use message preview transport for all DM streaming lanes so final delivery can edit the active preview instead of sending duplicate finals. Landed from contributor PR #38906 by @gambletan. Thanks @gambletan.
- Telegram/send retry safety: retry non-idempotent send paths only for pre-connect failures and make custom retry predicates strict, preventing ambiguous reconnect retries from sending duplicate messages. Landed from contributor PR #34238 by @hal-crackbot. Thanks @hal-crackbot.
- ACP/run spawn delivery bootstrap: stop reusing requester inline delivery targets for one-shot `mode: "run"` ACP spawns, so fresh run-mode workers bootstrap in isolation instead of inheriting thread-bound session delivery behavior. (#39014) Thanks @lidamao633.
- Discord/DM session-key normalization: rewrite legacy `discord:dm:*` and phantom direct-message `discord:channel:<user>` session keys to `discord:direct:*` when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly.
- Discord/native slash session fallback: treat empty configured bound-session keys as missing so `/status` and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings.
- Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across `toolCall`, `toolUse`, and `functionCall` blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with `Tool not found`. (#39328) Thanks @vincentkoc.

View File

@@ -310,6 +310,33 @@ describe("spawnAcpDirect", () => {
);
});
it("does not inline delivery for fresh oneshot ACP runs", async () => {
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "run",
},
{
agentSessionKey: "agent:main:telegram:direct:6098642967",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:6098642967",
agentThreadId: "1",
},
);
expect(result.status).toBe("accepted");
expect(result.mode).toBe("run");
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
expect(agentCall?.params?.deliver).toBe(false);
expect(agentCall?.params?.channel).toBeUndefined();
expect(agentCall?.params?.to).toBeUndefined();
expect(agentCall?.params?.threadId).toBeUndefined();
});
it("includes cwd in ACP thread intro banner when provided at spawn time", async () => {
const result = await spawnAcpDirect(
{
@@ -540,6 +567,32 @@ describe("spawnAcpDirect", () => {
expect(notifyOrder[0] > agentCallOrder).toBe(true);
});
it("keeps inline delivery for thread-bound ACP session mode", async () => {
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "session",
thread: true,
},
{
agentSessionKey: "agent:main:telegram:group:-1003342490704:topic:2",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:-1003342490704",
agentThreadId: "2",
},
);
expect(result.status).toBe("accepted");
expect(result.mode).toBe("session");
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
expect(agentCall?.params?.deliver).toBe(true);
expect(agentCall?.params?.channel).toBe("telegram");
});
it("disposes pre-registered parent relay when initial ACP dispatch fails", async () => {
const relayHandle = createRelayHandle();
hoisted.startAcpSpawnParentStreamRelayMock.mockReturnValueOnce(relayHandle);

View File

@@ -440,7 +440,10 @@ export async function spawnAcpDirect(
? `channel:${boundThreadId}`
: requesterOrigin?.to?.trim() || (deliveryThreadId ? `channel:${deliveryThreadId}` : undefined);
const hasDeliveryTarget = Boolean(requesterOrigin?.channel && inferredDeliveryTo);
const deliverToBoundTarget = hasDeliveryTarget && !streamToParentRequested;
// Fresh one-shot ACP runs should bootstrap the worker first, then let higher layers
// decide how to relay status. Inline delivery is reserved for thread-bound sessions.
const useInlineDelivery =
hasDeliveryTarget && spawnMode === "session" && !streamToParentRequested;
const childIdem = crypto.randomUUID();
let childRunId: string = childIdem;
const streamLogPath =
@@ -467,12 +470,12 @@ export async function spawnAcpDirect(
params: {
message: params.task,
sessionKey,
channel: hasDeliveryTarget ? requesterOrigin?.channel : undefined,
to: hasDeliveryTarget ? inferredDeliveryTo : undefined,
accountId: hasDeliveryTarget ? (requesterOrigin?.accountId ?? undefined) : undefined,
threadId: hasDeliveryTarget ? deliveryThreadId : undefined,
channel: useInlineDelivery ? requesterOrigin?.channel : undefined,
to: useInlineDelivery ? inferredDeliveryTo : undefined,
accountId: useInlineDelivery ? (requesterOrigin?.accountId ?? undefined) : undefined,
threadId: useInlineDelivery ? deliveryThreadId : undefined,
idempotencyKey: childIdem,
deliver: deliverToBoundTarget,
deliver: useInlineDelivery,
label: params.label || undefined,
},
timeoutMs: 10_000,