diff --git a/CHANGELOG.md b/CHANGELOG.md index 016a7dd6b79..352bd8048ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:` 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. diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index b9b768361b2..09f7f3f9bcd 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -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 }) + .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 }) + .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); diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 13cb66c2b54..829ce2ad530 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -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,