fix: A2A handoffs can duplicate message-tool replies (#97259)

* fix: stop A2A source-reply mirror duplicates

* fix(agents): harden A2A reply extraction

* fix(agents): preserve internal A2A source replies

* test(gateway): assert A2A mirror projection precisely

* test(agents): complete A2A payload fixtures

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
cavit99
2026-07-10 18:05:51 +01:00
committed by GitHub
parent 6154a42c0f
commit 5e250aac74
16 changed files with 1385 additions and 44 deletions
@@ -467,6 +467,75 @@ describe("qa mock openai server", () => {
expect(responseBody).toContain('"text":"QA-FINAL-ONLY-STREAMING-OK"');
});
it("plans sessions_send for the A2A message-tool mirror proof scenario", async () => {
const server = await startMockServer();
const prompt =
'qa a2a message-tool mirror check. sessionKey="agent:qa:a2a-target". exact marker: `QA-A2A-MIRROR-OK`';
const toolPlan = await expectResponsesJson(server, {
stream: false,
model: "gpt-5.5",
tools: [{ type: "function", name: "sessions_send" }],
input: [makeUserInput(prompt)],
});
const args = outputToolArgs(toolPlan);
expect(outputItem(toolPlan).type).toBe("function_call");
expect(outputItem(toolPlan).name).toBe("sessions_send");
expect(args).toMatchObject({
sessionKey: "agent:qa:a2a-target",
timeoutSeconds: 0,
});
expect(String(args.message)).toContain("qa group visible reply tool check");
expect(String(args.message)).toContain("QA-A2A-MIRROR-OK");
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);
const debugPayload = requireRecord(await debugResponse.json(), "debug request");
expect(debugPayload.plannedToolName).toBe("sessions_send");
expect(debugPayload.plannedToolArgs).toMatchObject({
sessionKey: "agent:qa:a2a-target",
timeoutSeconds: 0,
});
const final = await expectResponsesJson(server, {
stream: false,
model: "gpt-5.5",
tools: [{ type: "function", name: "sessions_send" }],
input: [
makeUserInput(prompt),
{
type: "function_call_output",
call_id: "call_mock_sessions_send_fixture",
output: JSON.stringify({ status: "accepted", delivery: { mode: "announce" } }),
},
],
});
expect(outputText(final)).toBe("");
const targetToolPlan = await expectResponsesJson(server, {
stream: false,
model: "gpt-5.5",
tools: [
{ type: "function", name: "sessions_send" },
{ type: "function", name: "message" },
],
input: [
makeUserInput(prompt),
makeUserInput(
"qa group visible reply tool check. Use the visible room reply path. exact marker: `QA-A2A-MIRROR-OK`",
),
],
});
expect(outputItem(targetToolPlan).type).toBe("function_call");
expect(outputItem(targetToolPlan).name).toBe("message");
expect(outputToolArgs(targetToolPlan)).toMatchObject({
action: "send",
message: "QA-A2A-MIRROR-OK",
});
});
it("emits deterministic text deltas for generic streaming QA prompts", async () => {
const server = await startMockServer();
@@ -169,13 +169,13 @@ const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
const QA_TOOL_PROGRESS_ERROR_PROMPT_RE = /tool progress error qa check/i;
const QA_TOOL_PROGRESS_PROMPT_RE = /tool progress qa check/i;
const QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE = /qa group visible reply tool check/i;
const QA_A2A_MESSAGE_TOOL_MIRROR_PROMPT_RE = /qa a2a message-tool mirror check/i;
const QA_GROUP_MESSAGE_UNAVAILABLE_FALLBACK_PROMPT_RE =
/qa group message unavailable fallback check/i;
const QA_STRANDED_FINAL_RECOVERY_PROMPT_RE = /qa stranded final recovery check/i;
const QA_STRANDED_FINAL_RETRY_FAILURE_PROMPT_RE = /qa stranded final retry failure check/i;
const QA_STRANDED_FINAL_RETRY_PROMPT_RE = /you did not call message\(action=send\)/i;
const QA_STRANDED_FINAL_RETRY_FAILURE_MARKER =
"QA-STRANDED-RETRY-FAIL-RAW";
const QA_STRANDED_FINAL_RETRY_FAILURE_MARKER = "QA-STRANDED-RETRY-FAIL-RAW";
const QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE = /telegram current session_status qa check/i;
const QA_TELEGRAM_STREAM_SINGLE_MARKER = "QA-TELEGRAM-STREAM-SINGLE-OK";
const QA_TELEGRAM_LONG_FINAL_THREE_CHUNK_PROMPT_RE = /telegram long final three chunk qa check/i;
@@ -1292,6 +1292,26 @@ function buildExplicitSessionsSpawnArgs(text: string): Record<string, unknown> |
};
}
function buildQaA2aMessageToolMirrorSessionsSendArgs(text: string): Record<string, unknown> | null {
if (!QA_A2A_MESSAGE_TOOL_MIRROR_PROMPT_RE.test(text)) {
return null;
}
const sessionKey =
extractQuotedToolArg(text, "sessionKey") ?? extractBareToolArg(text, "sessionKey");
if (!sessionKey) {
return null;
}
const marker =
extractExactMarkerDirective(text) ??
extractExactReplyDirective(text) ??
"QA-A2A-MESSAGE-TOOL-MIRROR-OK";
return {
sessionKey,
message: `qa group visible reply tool check. Use the visible room reply path. exact marker: \`${marker}\``,
timeoutSeconds: 0,
};
}
function extractToolErrorForNamedCall(params: {
input: ResponsesInputItem[];
name: string;
@@ -2569,6 +2589,15 @@ async function buildResponsesPayload(
}
return buildAssistantEvents(buildStrandedFinalRecoveryText());
}
if (QA_A2A_MESSAGE_TOOL_MIRROR_PROMPT_RE.test(prompt)) {
if (toolOutput) {
return buildAssistantEvents("");
}
const sessionsSendArgs = buildQaA2aMessageToolMirrorSessionsSendArgs(prompt);
if (sessionsSendArgs && hasDeclaredTool(body, "sessions_send")) {
return buildToolCallEventsWithArgs("sessions_send", sessionsSendArgs);
}
}
if (QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE.test(allInputText)) {
const marker = exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-TOOL-OK";
if (!toolOutput && hasDeclaredTool(body, "message")) {
@@ -0,0 +1,140 @@
title: A2A message-tool mirror dedupe
scenario:
id: a2a-message-tool-mirror-dedupe
surface: channel
coverage:
primary:
- runtime.delivery
secondary:
- channels.qa-channel
- tools.message
objective: Verify a sessions_send A2A turn whose nested target run replies through message(action=send) delivers once to the requester channel and does not re-announce the delivery-mirror transcript row.
gatewayConfigPatch:
messages:
groupChat:
visibleReplies: message_tool
session:
agentToAgent:
maxPingPongTurns: 0
tools:
sessions:
visibility: all
agentToAgent:
enabled: true
successCriteria:
- Source agent receives a synthetic qa-channel group turn.
- Source agent calls sessions_send against its real qa-channel group session.
- Target run calls message(action=send) under the source-reply path.
- The requester group sees the marker exactly once, with no duplicate during the post-delivery window.
docsRefs:
- docs/channels/qa-channel.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- src/agents/tools/sessions-send-tool.ts
- src/agents/tools/sessions-send-tool.a2a.ts
- src/agents/run-wait.ts
- src/shared/transcript-only-openclaw-assistant.ts
execution:
kind: flow
summary: Run a real QA Gateway/qa-channel A2A source-reply turn and assert the message-tool delivery mirror is not announced again.
config:
requiredProviderMode: mock-openai
requiredChannelDriver: qa-channel
conversationId: qa-a2a-mirror-room
conversationTitle: QA A2A Mirror Room
promptSnippet: qa a2a message-tool mirror check
targetPromptSnippet: qa group visible reply tool check
expectedMarker: QA-A2A-MESSAGE-TOOL-MIRROR-OK
duplicateWindowMs: 8000
flow:
steps:
- name: delivers target message-tool reply once without mirror re-announce
actions:
- assert:
expr: "env.providerMode === config.requiredProviderMode"
message: this seeded scenario is mock-openai only
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: waitForQaChannelReady
args:
- ref: env
- 60000
- call: reset
- set: startIndex
value:
expr: state.getSnapshot().messages.length
- set: targetSessionKey
value:
expr: "buildAgentSessionKey({ agentId: 'qa', channel: 'qa-channel', accountId: 'default', peer: { kind: 'group', id: `group:${config.conversationId}` }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
- set: requestCountBefore
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
- call: state.addInboundMessage
args:
- conversation:
id:
expr: config.conversationId
kind: group
title:
expr: config.conversationTitle
senderId: alice
senderName: Alice
text:
expr: "`@openclaw ${config.promptSnippet}. sessionKey=\"${targetSessionKey}\". Use sessions_send once with timeoutSeconds=0. The target must reply visibly with exact marker: \\`${config.expectedMarker}\\``"
- call: waitForCondition
saveAs: sourceSessionsSendRequest
args:
- lambda:
async: true
params: []
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).find((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'sessions_send' && request.plannedToolArgs?.sessionKey === targetSessionKey && request.plannedToolArgs?.timeoutSeconds === 0) : true"
- expr: liveTurnTimeoutMs(env, 60000)
- 500
- call: waitForCondition
saveAs: targetMessageToolRequest
args:
- lambda:
async: true
params: []
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).find((request) => String(request.allInputText ?? '').includes(config.targetPromptSnippet) && request.plannedToolName === 'message' && request.plannedToolArgs?.action === 'send' && request.plannedToolArgs?.message === config.expectedMarker) : true"
- expr: liveTurnTimeoutMs(env, 90000)
- 500
- call: waitForOutboundMessage
saveAs: outbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === config.conversationId && candidate.conversation.kind === 'group' && candidate.direction === 'outbound' && String(candidate.text ?? '').includes(config.expectedMarker)"
- expr: liveTurnTimeoutMs(env, 90000)
- sinceIndex:
ref: startIndex
- call: sleep
args:
- expr: config.duplicateWindowMs
- set: snapshot
value:
expr: state.getSnapshot()
- set: matchingOutbound
value:
expr: "snapshot.messages.slice(startIndex).filter((message) => message.direction === 'outbound' && message.conversation.id === config.conversationId && message.conversation.kind === 'group' && String(message.text ?? '').includes(config.expectedMarker))"
- assert:
expr: matchingOutbound.length === 1
message:
expr: "`expected exactly one requester-visible A2A marker after duplicate window, saw ${matchingOutbound.length}; transcript=${formatTransportTranscript(state, { conversationId: config.conversationId })}`"
- set: scenarioRequests
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).map((request) => ({ prompt: String(request.prompt ?? '').slice(0, 220), plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, toolOutput: request.toolOutput ? String(request.toolOutput).slice(0, 220) : null })) : []"
- assert:
expr: "!env.mock || scenarioRequests.filter((request) => request.plannedToolName === 'sessions_send' && request.plannedToolArgs?.sessionKey === targetSessionKey && request.plannedToolArgs?.timeoutSeconds === 0).length === 1"
message:
expr: "`expected exactly one source sessions_send plan for the target session; requests=${JSON.stringify(scenarioRequests)}`"
- assert:
expr: "!env.mock || scenarioRequests.filter((request) => request.plannedToolName === 'message' && request.plannedToolArgs?.action === 'send' && request.plannedToolArgs?.message === config.expectedMarker).length === 1"
message:
expr: "`expected exactly one target message(action=send) plan for the marker; requests=${JSON.stringify(scenarioRequests)}`"
detailsExpr: "`outbound=${outbound.conversation.kind}:${outbound.conversation.id}:${outbound.text}; sourceTool=${JSON.stringify(sourceSessionsSendRequest?.plannedToolArgs ?? {})}; targetTool=${JSON.stringify(targetMessageToolRequest?.plannedToolArgs ?? {})}; duplicateWindowMs=${config.duplicateWindowMs}`"
+487 -4
View File
@@ -76,7 +76,7 @@ function expectAgentWaitRequest(
describe("readLatestAssistantReply", () => {
beforeEach(() => {
callGatewayMock.mockClear();
callGatewayMock.mockReset();
testing.setDepsForTest({
callGateway: async (opts) => await callGatewayMock(opts),
});
@@ -117,6 +117,90 @@ describe("readLatestAssistantReply", () => {
expect(result).toBe("older output");
});
it("skips trailing transcript-only OpenClaw assistant mirrors for normal latest-reply reads", async () => {
callGatewayMock.mockResolvedValue({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "real worker reply" }],
timestamp: 10,
},
{
role: "assistant",
content: [{ type: "text", text: "already delivered through message tool" }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message-send",
},
timestamp: 11,
},
{
role: "assistant",
provider: "openclaw",
model: "gateway-injected",
content: [{ type: "text", text: "gateway notice" }],
timestamp: 12,
},
],
});
const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" });
expect(result).toBe("real worker reply");
});
it("skips trailing inter-session input rows for normal latest-reply reads", async () => {
callGatewayMock.mockResolvedValue({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "older worker reply" }],
timestamp: 10,
},
{
role: "assistant",
content: [{ type: "text", text: "forwarded sessions_send prompt" }],
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:main:source",
sourceTool: "sessions_send",
},
timestamp: 11,
},
],
});
const result = await readLatestAssistantReply({ sessionKey: "agent:main:target" });
expect(result).toBe("older worker reply");
});
it("stops at trailing transcript artifacts for waited reply extraction", async () => {
callGatewayMock.mockResolvedValue({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "older worker reply" }],
timestamp: 10,
},
{
role: "assistant",
provider: "openclaw",
model: "gateway-injected",
content: [{ type: "text", text: "gateway notice" }],
timestamp: 11,
},
],
});
const result = await readLatestAssistantReplySnapshot({
sessionKey: "agent:main:target",
stopAtTranscriptArtifact: true,
});
expect(result).toEqual({});
});
it("returns assistant fingerprints for delta comparisons", async () => {
callGatewayMock.mockResolvedValue({
messages: [
@@ -194,7 +278,7 @@ describe("readLatestAssistantReply", () => {
describe("waitForAgentRun", () => {
beforeEach(() => {
callGatewayMock.mockClear();
callGatewayMock.mockReset();
testing.setDepsForTest({
callGateway: async (opts) => await callGatewayMock(opts),
});
@@ -380,7 +464,7 @@ describe("waitForAgentRun", () => {
describe("waitForAgentRunAndReadUpdatedAssistantReply", () => {
beforeEach(() => {
callGatewayMock.mockClear();
callGatewayMock.mockReset();
testing.setDepsForTest({
callGateway: async (opts) => await callGatewayMock(opts),
});
@@ -416,6 +500,364 @@ describe("waitForAgentRunAndReadUpdatedAssistantReply", () => {
});
});
it("returns undefined when a text-only baseline matches the latest assistant reply", async () => {
callGatewayMock
.mockResolvedValueOnce({
status: "ok",
})
.mockResolvedValueOnce({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "same reply" }],
timestamp: 42,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-text-baseline",
sessionKey: "agent:main:child",
timeoutMs: 1_000,
baseline: {
text: "same reply",
},
});
expect(result).toEqual({
status: "ok",
replyText: undefined,
});
});
it("does not treat a message-tool delivery mirror as a new waited reply", async () => {
const baselineMessage = {
role: "assistant",
content: [{ type: "text", text: "previous real reply" }],
timestamp: 41,
};
callGatewayMock
.mockResolvedValueOnce({
status: "ok",
})
.mockResolvedValueOnce({
messages: [
baselineMessage,
{
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: "already delivered source reply" }],
timestamp: 42,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-source-reply",
sessionKey: "agent:main:child",
timeoutMs: 1_000,
baseline: {
text: "previous real reply",
fingerprint: JSON.stringify(baselineMessage),
},
});
expect(result).toEqual({
status: "ok",
replyText: undefined,
});
});
it("does not treat a projected message-tool mirror as a new waited reply", async () => {
const baselineMessage = {
role: "assistant",
content: [{ type: "text", text: "previous real reply" }],
timestamp: 41,
};
callGatewayMock
.mockResolvedValueOnce({
status: "ok",
})
.mockResolvedValueOnce({
messages: [
baselineMessage,
{
role: "assistant",
content: [{ type: "text", text: "already delivered source reply" }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message-send",
},
timestamp: 42,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-projected-source-reply",
sessionKey: "agent:main:child",
timeoutMs: 1_000,
baseline: {
text: "previous real reply",
fingerprint: JSON.stringify(baselineMessage),
},
});
expect(result).toEqual({
status: "ok",
replyText: undefined,
});
});
it("returns a projected message-tool reply held for outer A2A delivery", async () => {
callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({
messages: [
{
role: "assistant",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:main:source",
sourceTool: "sessions_send",
},
content: [{ type: "text", text: "forwarded request" }],
__openclaw: { seq: 41 },
timestamp: 41,
},
{
role: "assistant",
content: [{ type: "text", text: "source reply awaiting delivery" }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message-send",
sourceReplySink: "internal-ui",
sourceMessageSeq: 42,
},
timestamp: 42,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-internal-source-reply",
sessionKey: "agent:worker:main",
timeoutMs: 1_000,
});
expect(result).toEqual({
status: "ok",
replyText: "source reply awaiting delivery",
});
});
it("prefers an internal source reply over a later private final", async () => {
callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({
messages: [
{
role: "assistant",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:main:source",
sourceTool: "sessions_send",
},
content: [{ type: "text", text: "forwarded request" }],
__openclaw: { seq: 41 },
timestamp: 41,
},
{
role: "assistant",
content: [{ type: "text", text: "source reply awaiting delivery" }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message-send",
sourceReplySink: "internal-ui",
sourceMessageSeq: 42,
},
timestamp: 42,
},
{
role: "assistant",
content: [{ type: "text", text: "Done" }],
timestamp: 43,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-internal-source-reply-with-private-final",
sessionKey: "agent:worker:main",
timeoutMs: 1_000,
});
expect(result).toEqual({
status: "ok",
replyText: "source reply awaiting delivery",
});
});
it("does not let a late internal result cross an inter-session turn boundary", async () => {
callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({
messages: [
{
role: "assistant",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:main:source",
sourceTool: "sessions_send",
},
content: [{ type: "text", text: "new forwarded request" }],
__openclaw: { seq: 42 },
timestamp: 42,
},
{
role: "assistant",
content: [{ type: "text", text: "stale source reply" }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message-before-request",
sourceReplySink: "internal-ui",
sourceMessageSeq: 41,
},
timestamp: 41,
},
{
role: "assistant",
content: [{ type: "text", text: "fresh reply" }],
timestamp: 43,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-after-late-internal-source-reply",
sessionKey: "agent:worker:main",
timeoutMs: 1_000,
});
expect(result).toEqual({
status: "ok",
replyText: "fresh reply",
});
});
it("does not return a private final written after a message-tool delivery mirror", async () => {
callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({
messages: [
{
role: "assistant",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:main:source",
sourceTool: "sessions_send",
},
content: [{ type: "text", text: "forwarded request" }],
timestamp: 41,
},
{
role: "assistant",
content: [{ type: "text", text: "already delivered source reply" }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message-send",
},
timestamp: 42,
},
{
role: "assistant",
content: [{ type: "text", text: "Done" }],
timestamp: 43,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-source-reply-with-private-final",
sessionKey: "agent:main:child",
timeoutMs: 1_000,
});
expect(result).toEqual({
status: "ok",
replyText: undefined,
});
});
it("does not let an older turn's message-tool mirror suppress a fresh reply", async () => {
callGatewayMock.mockResolvedValueOnce({ status: "ok" }).mockResolvedValueOnce({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "older delivered reply" }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-older-message-send",
},
timestamp: 40,
},
{
role: "assistant",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:main:source",
sourceTool: "sessions_send",
},
content: [{ type: "text", text: "new forwarded request" }],
timestamp: 41,
},
{
role: "assistant",
content: [{ type: "text", text: "fresh reply" }],
timestamp: 42,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-after-older-source-reply",
sessionKey: "agent:main:child",
timeoutMs: 1_000,
});
expect(result).toEqual({
status: "ok",
replyText: "fresh reply",
});
});
it("does not resurrect an older reply when only a delivery mirror is newer", async () => {
callGatewayMock
.mockResolvedValueOnce({
status: "ok",
})
.mockResolvedValueOnce({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "stale previous reply" }],
timestamp: 41,
},
{
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: "already delivered source reply" }],
timestamp: 42,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-source-reply-without-baseline",
sessionKey: "agent:main:child",
timeoutMs: 1_000,
});
expect(result).toEqual({
status: "ok",
replyText: undefined,
});
});
it("returns the new assistant text when the fingerprint changes", async () => {
callGatewayMock
.mockResolvedValueOnce({
@@ -446,11 +888,52 @@ describe("waitForAgentRunAndReadUpdatedAssistantReply", () => {
replyText: "fresh reply",
});
});
it("preserves successful wait metadata when returning an updated reply", async () => {
callGatewayMock
.mockResolvedValueOnce({
status: "ok",
startedAt: 100,
endedAt: 200,
stopReason: "completed",
yielded: true,
providerStarted: true,
})
.mockResolvedValueOnce({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "fresh reply" }],
timestamp: 99,
},
],
});
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId: "run-with-metadata",
sessionKey: "agent:main:child",
timeoutMs: 1_000,
baseline: {
text: "older reply",
fingerprint: "old-fingerprint",
},
});
expect(result).toEqual({
status: "ok",
startedAt: 100,
endedAt: 200,
stopReason: "completed",
yielded: true,
providerStarted: true,
replyText: "fresh reply",
});
});
});
describe("waitForAgentRunsToDrain", () => {
beforeEach(() => {
callGatewayMock.mockClear();
callGatewayMock.mockReset();
testing.setDepsForTest({
callGateway: async (opts) => await callGatewayMock(opts),
});
+162 -16
View File
@@ -6,6 +6,7 @@
import {
addTimerTimeoutGraceMs,
asDateTimestampMs,
asPositiveSafeInteger,
clampTimerTimeoutMs,
parseFiniteNumber,
resolveDateTimestampMs,
@@ -14,6 +15,11 @@ import {
import { callGateway } from "../gateway/call.js";
import { formatErrorMessage } from "../infra/errors.js";
import { normalizeBlockedLivenessWaitStatus } from "../shared/agent-liveness.js";
import {
isOpenClawInternalSourceReplyMirrorAssistantMessage,
isOpenClawMessageToolMirrorAssistantMessage,
isTranscriptOnlyOpenClawAssistantMessage,
} from "../shared/transcript-only-openclaw-assistant.js";
import {
buildAgentRunTerminalOutcomeFromWaitResult,
type AgentRunTerminalOutcome,
@@ -160,34 +166,174 @@ function normalizePendingRunIds(runIds: Iterable<string>): string[] {
return [...seen];
}
function resolveLatestAssistantReplySnapshot(messages: unknown[]): AssistantReplySnapshot {
function isWaitedReplyTranscriptArtifact(message: unknown): boolean {
return (
isTranscriptOnlyOpenClawAssistantMessage(message) ||
isOpenClawMessageToolMirrorAssistantMessage(message) ||
isInterSessionInputMessage(message)
);
}
function isInterSessionInputMessage(message: unknown): boolean {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return false;
}
const provenance = (message as { provenance?: unknown }).provenance;
return (
Boolean(provenance) &&
typeof provenance === "object" &&
!Array.isArray(provenance) &&
(provenance as { kind?: unknown }).kind === "inter_session"
);
}
function isWaitedReplyTurnBoundary(message: unknown): boolean {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return false;
}
return (message as { role?: unknown }).role === "user" || isInterSessionInputMessage(message);
}
function snapshotAssistantReply(message: unknown): AssistantReplySnapshot | undefined {
const text = extractAssistantText(message);
if (!text?.trim()) {
return undefined;
}
let fingerprint: string | undefined;
try {
fingerprint = JSON.stringify(message);
} catch {
fingerprint = text;
}
return { text, fingerprint };
}
function readTranscriptMessageSeq(message: unknown): number | undefined {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return undefined;
}
const meta = (message as { __openclaw?: unknown })["__openclaw"];
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return undefined;
}
return asPositiveSafeInteger((meta as { seq?: unknown }).seq);
}
function readInternalSourceReplyMessageSeq(message: unknown): number | undefined {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return undefined;
}
const marker = (message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror;
if (!marker || typeof marker !== "object" || Array.isArray(marker)) {
return undefined;
}
return asPositiveSafeInteger((marker as { sourceMessageSeq?: unknown }).sourceMessageSeq);
}
function resolveLatestAssistantReplySnapshot(
messages: unknown[],
opts?: { stopAtTranscriptArtifact?: boolean },
): AssistantReplySnapshot {
let latestReply: AssistantReplySnapshot = {};
const internalSourceReplies: Array<{
snapshot: AssistantReplySnapshot;
sourceMessageSeq?: number;
}> = [];
let sawTranscriptArtifact = false;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const candidate = messages[i];
if (!candidate || typeof candidate !== "object") {
continue;
}
if (opts?.stopAtTranscriptArtifact === true && isWaitedReplyTurnBoundary(candidate)) {
const boundarySeq = readTranscriptMessageSeq(candidate);
const currentInternalSourceReply = boundarySeq
? internalSourceReplies.find(
(reply) => reply.sourceMessageSeq !== undefined && reply.sourceMessageSeq > boundarySeq,
)
: undefined;
if (currentInternalSourceReply) {
return currentInternalSourceReply.snapshot;
}
if (!boundarySeq && internalSourceReplies.length > 0) {
sawTranscriptArtifact = true;
}
internalSourceReplies.length = 0;
break;
}
if ((candidate as { role?: unknown }).role !== "assistant") {
continue;
}
const text = extractAssistantText(candidate);
if (!text?.trim()) {
if (
opts?.stopAtTranscriptArtifact === true &&
isOpenClawInternalSourceReplyMirrorAssistantMessage(candidate)
) {
// Internal source replies still need the outer A2A flow to deliver them.
// The source seq prevents a late old result from crossing a new turn.
const snapshot = snapshotAssistantReply(candidate);
const sourceMessageSeq = readInternalSourceReplyMessageSeq(candidate);
if (snapshot) {
internalSourceReplies.push({ snapshot, sourceMessageSeq });
}
if (!sourceMessageSeq) {
sawTranscriptArtifact = true;
}
continue;
}
let fingerprint: string | undefined;
try {
fingerprint = JSON.stringify(candidate);
} catch {
fingerprint = text;
if (isWaitedReplyTranscriptArtifact(candidate)) {
if (opts?.stopAtTranscriptArtifact === true) {
sawTranscriptArtifact = true;
}
continue;
}
const snapshot = snapshotAssistantReply(candidate);
if (!snapshot) {
continue;
}
if (opts?.stopAtTranscriptArtifact !== true) {
return snapshot;
}
if (!latestReply.text) {
latestReply = snapshot;
}
return { text, fingerprint };
}
return {};
if (opts?.stopAtTranscriptArtifact === true) {
if (internalSourceReplies.length > 0) {
sawTranscriptArtifact = true;
}
if (sawTranscriptArtifact) {
return {};
}
}
return latestReply;
}
export function hasUpdatedAssistantReplySnapshot(
latestReply: AssistantReplySnapshot,
baseline: AssistantReplySnapshot | undefined,
): boolean {
if (!latestReply.text) {
return false;
}
if (!baseline) {
return true;
}
if (baseline.fingerprint !== undefined) {
return latestReply.fingerprint !== baseline.fingerprint;
}
if (baseline.text !== undefined) {
return latestReply.text !== baseline.text;
}
return true;
}
/** Read the latest non-tool assistant message for a session. */
export async function readLatestAssistantReplySnapshot(params: {
sessionKey: string;
limit?: number;
// Waited reply paths stop at transcript artifacts so they do not resurrect
// an older assistant message as a fresh post-run reply.
stopAtTranscriptArtifact?: boolean;
callGateway?: GatewayCaller;
}): Promise<AssistantReplySnapshot> {
const history = await (params.callGateway ?? runWaitDeps.callGateway)<{
@@ -198,6 +344,7 @@ export async function readLatestAssistantReplySnapshot(params: {
});
return resolveLatestAssistantReplySnapshot(
stripToolMessages(Array.isArray(history?.messages) ? history.messages : []),
{ stopAtTranscriptArtifact: params.stopAtTranscriptArtifact },
);
}
@@ -272,15 +419,14 @@ export async function waitForAgentRunAndReadUpdatedAssistantReply(params: {
const latestReply = await readLatestAssistantReplySnapshot({
sessionKey: params.sessionKey,
limit: params.limit,
stopAtTranscriptArtifact: true,
callGateway: params.callGateway,
});
const baselineFingerprint = params.baseline?.fingerprint;
const replyText =
latestReply.text && (!baselineFingerprint || latestReply.fingerprint !== baselineFingerprint)
? latestReply.text
: undefined;
const replyText = hasUpdatedAssistantReplySnapshot(latestReply, params.baseline)
? latestReply.text
: undefined;
return {
status: "ok",
...wait,
replyText,
};
}
+71
View File
@@ -132,4 +132,75 @@ describe("runAgentStep", () => {
expect(ingress?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(ingress?.transcriptMessage).toBe("");
});
it("does not return failed transcript-mode output as an announce reply", async () => {
const agentCommandFromIngress = vi.fn(async () => ({
payloads: [
{
text: "⚠️ Agent couldn't generate a response. Please try again.",
mediaUrl: null,
isError: true,
},
],
meta: {
durationMs: 1,
error: {
kind: "incomplete_turn" as const,
message: "Agent couldn't generate a response.",
fallbackSafe: true,
terminalPresentation: false,
},
},
}));
testing.setDepsForTest({
agentCommandFromIngress,
callGateway: async <T = unknown>(): Promise<T> => ({ runId: "unused" }) as T,
});
await expect(
runAgentStep({
sessionKey: "agent:main:subagent:child",
message: "internal announce step",
transcriptMessage: "",
extraSystemPrompt: "announce only",
timeoutMs: 10_000,
}),
).resolves.toBeUndefined();
expect(bundleMcpRuntimeMocks.retireSessionMcpRuntimeForSessionKey).toHaveBeenCalledWith({
sessionKey: "agent:main:subagent:child",
reason: "nested-agent-step-complete",
});
});
it("returns trusted terminal presentations from incomplete transcript turns", async () => {
const presentation =
"The read-only lookup completed successfully.\n\n⚠️ Agent couldn't generate a response. Please try again.";
const agentCommandFromIngress = vi.fn(async () => ({
payloads: [{ text: presentation, mediaUrl: null, isError: true }],
meta: {
durationMs: 1,
error: {
kind: "incomplete_turn" as const,
message: "Agent couldn't generate a response.",
fallbackSafe: true,
terminalPresentation: true,
},
},
}));
testing.setDepsForTest({
agentCommandFromIngress,
callGateway: async <T = unknown>(): Promise<T> => ({ runId: "unused" }) as T,
});
await expect(
runAgentStep({
sessionKey: "agent:main:subagent:child",
message: "internal announce step",
transcriptMessage: "",
extraSystemPrompt: "announce only",
timeoutMs: 10_000,
}),
).resolves.toBe(presentation);
});
});
+12 -1
View File
@@ -28,7 +28,18 @@ let agentStepDeps: {
} = defaultAgentStepDeps;
function extractAgentCommandReply(result: unknown): string | undefined {
const payloads = (result as { payloads?: unknown } | undefined)?.payloads;
const candidate = result as { meta?: { error?: unknown }; payloads?: unknown } | null | undefined;
const error =
candidate?.meta?.error &&
typeof candidate.meta.error === "object" &&
!Array.isArray(candidate.meta.error)
? (candidate.meta.error as { kind?: unknown; terminalPresentation?: unknown })
: undefined;
// Plain incomplete-turn output is a control failure; trusted terminal tool presentations remain deliverable.
if (error?.kind === "incomplete_turn" && error.terminalPresentation !== true) {
return undefined;
}
const payloads = candidate?.payloads;
if (!Array.isArray(payloads)) {
return undefined;
}
@@ -16,9 +16,9 @@ vi.mock("../../gateway/call.js", () => ({
}));
vi.mock("../run-wait.js", async (importOriginal) => {
const { isRecoverableAgentWaitError } = await importOriginal<typeof import("../run-wait.js")>();
const actual = await importOriginal<typeof import("../run-wait.js")>();
return {
isRecoverableAgentWaitError,
...actual,
waitForAgentRun: vi.fn().mockResolvedValue({ status: "ok" }),
readLatestAssistantReplySnapshot: vi.fn().mockResolvedValue({
text: "Test announce reply",
@@ -227,6 +227,25 @@ describe("runSessionsSendA2AFlow announce delivery", () => {
expect(gatewayCalls.find((call) => call.method === "send")).toBeUndefined();
});
it("delivers a legitimate reply that quotes incomplete-turn text", async () => {
const reply = 'The log says "Agent couldn\'t generate a response", but the retry succeeded.';
await runSessionsSendA2AFlow({
targetSessionKey: "agent:main:discord:channel:target-room",
displayKey: "agent:main:discord:channel:target-room",
message: "Diagnose the failed turn",
announceTimeoutMs: 10_000,
maxPingPongTurns: 2,
requesterSessionKey: "agent:main:discord:channel:target-room",
requesterChannel: "discord",
roundOneReply: reply,
});
expect(runAgentStep).not.toHaveBeenCalled();
const sendCall = requireGatewayCall("send");
expect((sendCall.params as Record<string, unknown>).message).toBe(reply);
});
it("keeps the announce decider for same-session sends from a different channel", async () => {
vi.mocked(runAgentStep).mockResolvedValueOnce("ANNOUNCE_SKIP");
@@ -247,6 +266,22 @@ describe("runSessionsSendA2AFlow announce delivery", () => {
expect(gatewayCalls.find((call) => call.method === "send")).toBeUndefined();
});
it("does not run the announce decider for same-session sends without an announce target", async () => {
await runSessionsSendA2AFlow({
targetSessionKey: "agent:main:main",
displayKey: "agent:main:main",
message: "Test message",
announceTimeoutMs: 10_000,
maxPingPongTurns: 2,
requesterSessionKey: "agent:main:main",
requesterChannel: "qa-channel",
roundOneReply: "Already delivered through the source message tool",
});
expect(runAgentStep).not.toHaveBeenCalled();
expect(gatewayCalls.find((call) => call.method === "send")).toBeUndefined();
});
it.each([
{
source: "deliveryContext.accountId",
@@ -472,6 +507,31 @@ describe("runSessionsSendA2AFlow announce delivery", () => {
});
});
it("does not inject a delayed reply that matches a text-only baseline", async () => {
vi.mocked(readLatestAssistantReplySnapshot).mockResolvedValueOnce({
text: "same reply",
fingerprint: "same-reply-new-fingerprint",
});
await runSessionsSendA2AFlow({
targetSessionKey: "agent:main:discord:group:dev",
displayKey: "agent:main:discord:group:dev",
message: "Test message",
announceTimeoutMs: 10_000,
maxPingPongTurns: 2,
requesterSessionKey: "agent:main:discord:group:req",
requesterChannel: "discord",
baseline: {
text: "same reply",
},
waitRunId: "run-delayed",
});
expect(firstMockArg(vi.mocked(waitForAgentRun), "agent run wait").runId).toBe("run-delayed");
expect(runAgentStep).not.toHaveBeenCalled();
expect(gatewayCalls.find((call) => call.method === "send")).toBeUndefined();
});
it.each(["NO_REPLY", "HEARTBEAT_OK", "ANNOUNCE_SKIP"])(
"suppresses exact announce control reply %s before channel delivery",
async (announceReply) => {
+14 -13
View File
@@ -12,6 +12,7 @@ import { resolveNestedAgentLaneForSession } from "../lanes.js";
import {
type AgentWaitResult,
type AssistantReplySnapshot,
hasUpdatedAssistantReplySnapshot,
isRecoverableAgentWaitError,
readLatestAssistantReplySnapshot,
waitForAgentRun,
@@ -107,14 +108,12 @@ export async function runSessionsSendA2AFlow(params: {
if (wait.status === "ok") {
const latestSnapshot = await readLatestAssistantReplySnapshot({
sessionKey: params.targetSessionKey,
stopAtTranscriptArtifact: true,
callGateway: sessionsSendA2ADeps.callGateway,
});
const baselineFingerprint = params.baseline?.fingerprint;
primaryReply =
latestSnapshot.text &&
(!baselineFingerprint || latestSnapshot.fingerprint !== baselineFingerprint)
? latestSnapshot.text
: undefined;
primaryReply = hasUpdatedAssistantReplySnapshot(latestSnapshot, params.baseline)
? latestSnapshot.text
: undefined;
latestReply = primaryReply;
} else {
if (
@@ -155,14 +154,13 @@ export async function runSessionsSendA2AFlow(params: {
// A same-session send is a human-facing source-channel reply, not a true
// agent-to-agent announcement. Asking the same session to decide whether to
// announce can learn stale ANNOUNCE_SKIP patterns from its own history and
// silently drop a normal channel response.
if (
// announce can re-run the same prompt and duplicate source-reply side effects.
const sameSessionSourceReply =
params.requesterSessionKey && params.requesterSessionKey === params.targetSessionKey;
const canDirectDeliverSameSessionReply =
announceTarget &&
params.requesterSessionKey &&
params.requesterSessionKey === params.targetSessionKey &&
params.requesterChannel === announceTarget.channel
) {
(!params.requesterChannel || params.requesterChannel === announceTarget.channel);
if (sameSessionSourceReply && canDirectDeliverSameSessionReply) {
if (params.waitRunId && !params.roundOneReply && !params.baseline) {
return;
}
@@ -173,6 +171,9 @@ export async function runSessionsSendA2AFlow(params: {
});
return;
}
if (sameSessionSourceReply && !announceTarget) {
return;
}
if (
params.maxPingPongTurns > 0 &&
+5 -5
View File
@@ -555,7 +555,7 @@ export function createSessionsSendTool(opts?: {
});
}
const requesterSessionKey = opts?.agentSessionKey;
const requesterSessionKey = opts?.agentSessionKey ? effectiveRequesterKey : undefined;
const requesterChannel = opts?.agentChannel;
const sameSessionA2A = requesterSessionKey === resolvedKey;
const isIsolatedCronRequester = isCronRunSessionKey(requesterSessionKey);
@@ -597,14 +597,14 @@ export function createSessionsSendTool(opts?: {
: undefined;
const agentMessageContext = buildAgentToAgentMessageContext({
requesterSessionKey: opts?.agentSessionKey,
requesterChannel: opts?.agentChannel,
requesterSessionKey,
requesterChannel,
targetSessionKey: displayKey,
});
const inputProvenance = {
kind: "inter_session" as const,
sourceSessionKey: opts?.agentSessionKey,
sourceChannel: opts?.agentChannel,
sourceSessionKey: requesterSessionKey,
sourceChannel: requesterChannel,
sourceTool: "sessions_send",
};
const sendParams = {
+49
View File
@@ -1195,6 +1195,55 @@ describe("sessions_send gating", () => {
expect(flowParams?.baseline?.text).toBe("older reply from a previous run");
});
it("canonicalizes aliased requester keys for same-session A2A delivery", async () => {
const { runSessionsSendA2AFlow } = await import("./sessions-send-tool.a2a.js");
vi.mocked(runSessionsSendA2AFlow).mockClear();
const tool = createSessionsSendTool({
agentSessionKey: "main",
agentChannel: MAIN_AGENT_CHANNEL,
config: {
session: { scope: "per-sender", mainKey: MAIN_AGENT_SESSION_KEY },
tools: { agentToAgent: { enabled: false } },
} as never,
});
const staleAssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "older reply from a previous run" }],
timestamp: 20,
};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "sessions.list") {
return {
path: "/tmp/sessions.json",
sessions: [{ key: MAIN_AGENT_SESSION_KEY, kind: "direct" }],
};
}
if (request.method === "chat.history") {
return { messages: [staleAssistantMessage] };
}
if (request.method === "agent") {
return { runId: "run-alias-fire-and-forget", acceptedAt: 123 };
}
return {};
});
const result = await tool.execute("call-aliased-fire-and-forget-same-session", {
sessionKey: MAIN_AGENT_SESSION_KEY,
message: "ping",
timeoutSeconds: 0,
});
const details = requireDetails(result);
expect(details.status).toBe("accepted");
expect(details.sessionKey).toBe("main");
const flowParams = vi.mocked(runSessionsSendA2AFlow).mock.calls[0]?.[0];
expect(flowParams?.requesterSessionKey).toBe(MAIN_AGENT_SESSION_KEY);
expect(flowParams?.targetSessionKey).toBe(MAIN_AGENT_SESSION_KEY);
expect(flowParams?.baseline?.text).toBe("older reply from a previous run");
});
it("accepts fire-and-forget same-session sends when baseline history is unavailable", async () => {
const { runSessionsSendA2AFlow } = await import("./sessions-send-tool.a2a.js");
vi.mocked(runSessionsSendA2AFlow).mockClear();
+19 -1
View File
@@ -1,7 +1,10 @@
// Gateway chat display projection.
// Converts raw transcript messages into bounded Control UI/history display records.
import { createHash } from "node:crypto";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import {
asFiniteNumber,
asPositiveSafeInteger,
} from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
@@ -39,6 +42,7 @@ type PendingMessageToolVisibleReply = {
completionAnchor?: Record<string, unknown>;
deliveryMirrorAnchor?: Record<string, unknown>;
deliveryMirrorIndex?: number;
sourceReplySink?: "internal-ui";
succeeded: boolean;
};
@@ -928,15 +932,25 @@ function isSuccessfulMessageToolResultPayload(message: Record<string, unknown>):
return ok !== false;
}
function readMessageToolSourceReplySink(
message: Record<string, unknown>,
): "internal-ui" | undefined {
const details = readRecord(message.details);
return details?.sourceReplySink === "internal-ui" ? "internal-ui" : undefined;
}
function buildMessageToolVisibleReplyMirror(
pending: PendingMessageToolVisibleReply,
): Record<string, unknown> {
const sourceMessageSeq = asPositiveSafeInteger(readRecord(pending.anchor["__openclaw"])?.seq);
const mirror: Record<string, unknown> = {
role: "assistant",
content: [{ type: "text", text: pending.text }],
openclawMessageToolMirror: {
toolName: "message",
...(pending.toolCallId ? { toolCallId: pending.toolCallId } : {}),
...(pending.sourceReplySink ? { sourceReplySink: pending.sourceReplySink } : {}),
...(pending.sourceReplySink && sourceMessageSeq ? { sourceMessageSeq } : {}),
},
};
for (const field of ["timestamp", "createdAt", "agentId"] as const) {
@@ -1054,6 +1068,10 @@ function mirrorMessageToolVisibleReplies(messages: unknown[]): unknown[] {
for (const item of pending) {
if (!item.succeeded && isSuccessfulMessageToolResult(record, item)) {
item.succeeded = true;
const sourceReplySink = readMessageToolSourceReplySink(record);
if (sourceReplySink) {
item.sourceReplySink = sourceReplySink;
}
item.completionAnchor = item.deliveryMirrorAnchor ?? record;
if (item.deliveryMirrorAnchor) {
if (typeof item.deliveryMirrorIndex === "number") {
@@ -1432,6 +1432,7 @@ describe("projectRecentChatDisplayMessages", () => {
args: { action: "send", message: "visible via message tool" },
},
],
__openclaw: { seq: 1 },
timestamp: 1,
},
{
@@ -1442,6 +1443,7 @@ describe("projectRecentChatDisplayMessages", () => {
sourceSessionKey: "agent:main:webchat:source",
sourceTool: "sessions_send",
},
__openclaw: { seq: 2 },
timestamp: 2,
},
{
@@ -1449,6 +1451,7 @@ describe("projectRecentChatDisplayMessages", () => {
toolName: "message",
toolCallId: "call-message",
content: JSON.stringify({ ok: true }),
details: { sourceReplySink: "internal-ui" },
timestamp: 3,
},
{
@@ -1469,6 +1472,7 @@ describe("projectRecentChatDisplayMessages", () => {
args: { action: "send", message: "visible via message tool" },
},
],
__openclaw: { seq: 1 },
timestamp: 1,
},
{
@@ -1480,6 +1484,7 @@ describe("projectRecentChatDisplayMessages", () => {
sourceSessionKey: "agent:main:webchat:source",
sourceTool: "sessions_send",
},
__openclaw: { seq: 2 },
timestamp: 2,
},
{
@@ -1495,6 +1500,8 @@ describe("projectRecentChatDisplayMessages", () => {
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message",
sourceReplySink: "internal-ui",
sourceMessageSeq: 1,
},
timestamp: 1,
},
@@ -870,6 +870,64 @@ describe("gateway server chat", () => {
).toBe(true);
});
test("chat.history marks message-tool replies held for internal source delivery", async () => {
const replyText = "Forward this source reply.";
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-internal-source",
name: "message",
arguments: {
action: "send",
message: replyText,
},
},
],
timestamp: 1,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-internal-source",
content: [{ type: "text", text: "Sent visible reply via internal-ui." }],
details: {
status: "ok",
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
},
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 3,
},
]);
const visibleAssistantMessages = historyMessages.filter((message) => {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as { role?: unknown };
return entry.role === "assistant" && extractFirstTextBlock(message) !== undefined;
});
expect(visibleAssistantMessages).toEqual([
expect.objectContaining({
role: "assistant",
content: [{ type: "text", text: replyText }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message-internal-source",
sourceReplySink: "internal-ui",
sourceMessageSeq: 1,
},
}),
]);
});
test("chat.history hides raw delivery-mirror rows but keeps message-tool mirrors", async () => {
const replyText = "One visible send.";
const historyMessages = await loadChatHistoryWithMessages([
+178
View File
@@ -292,6 +292,184 @@ describe("sessions_send gateway loopback", () => {
}
},
);
it(
"does not re-announce a trailing message-tool delivery mirror after a waited A2A run",
{ timeout: SESSION_SEND_E2E_TIMEOUT_MS },
async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-send-mirror-"));
const sessionKey = "agent:main:whatsapp:direct:peer-1";
const sessionId = "sess-whatsapp-mirror";
const sessionFile = path.join(dir, `${sessionId}.jsonl`);
const runId = `run-message-tool-mirror-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const deliveredReply = "already delivered source reply";
const sendCalls: Array<{
to?: string;
text?: string;
accountId?: string | null;
threadId?: string | number | null;
}> = [];
setTestPluginRegistry(
createTestRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: createOutboundTestPlugin({
id: "whatsapp",
label: "WhatsApp",
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) => {
const target = to?.trim();
return target
? { ok: true, to: target }
: { ok: false, error: new Error("missing target") };
},
sendText: async (ctx) => {
sendCalls.push({
to: ctx.to,
text: ctx.text,
accountId: ctx.accountId,
threadId: ctx.threadId,
});
return { channel: "whatsapp", messageId: "wa-duplicate-proof-msg" };
},
},
messaging: {
normalizeTarget: (raw) => raw,
},
}),
},
]),
);
testState.sessionStorePath = path.join(dir, "sessions.json");
try {
await writeSessionStore({
entries: {
[sessionKey]: {
sessionId,
sessionFile,
updatedAt: Date.now(),
deliveryContext: {
channel: "whatsapp",
to: "peer-1",
},
origin: {
provider: "whatsapp",
accountId: "work",
threadId: "thread-77",
},
},
},
});
await fs.writeFile(
sessionFile,
[
{
message: {
role: "assistant",
content: [{ type: "text", text: "previous real reply" }],
timestamp: 1,
},
},
{
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-duplicate-proof",
name: "message",
arguments: {
action: "send",
message: deliveredReply,
},
},
],
timestamp: 2,
},
},
{
message: {
role: "toolResult",
toolName: "message",
toolCallId: "call-message-duplicate-proof",
content: { ok: true, messageId: "24271", chatId: "peer-1" },
timestamp: 3,
},
},
{
message: {
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: deliveredReply }],
timestamp: 4,
},
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf-8",
);
const { callGateway } = await import("./call.js");
const history = await callGateway<{ messages?: unknown[] }>({
method: "chat.history",
params: { sessionKey, limit: 10 },
timeoutMs: 5_000,
});
expect(history.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining([
expect.objectContaining({ type: "text", text: deliveredReply }),
]),
openclawMessageToolMirror: expect.objectContaining({
toolName: "message",
toolCallId: "call-message-duplicate-proof",
}),
}),
]),
);
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "start", startedAt },
});
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "end", startedAt, endedAt: Date.now() },
});
agentStepTesting.setDepsForTest({
agentCommandFromIngress: async () => ({
payloads: [{ text: "SHOULD_NOT_SEND", mediaUrl: null }],
meta: { durationMs: 1 },
}),
});
await runSessionsSendA2AFlow({
targetSessionKey: sessionKey,
displayKey: sessionKey,
message: "proof ping",
announceTimeoutMs: 5_000,
maxPingPongTurns: 0,
waitRunId: runId,
});
expect(sendCalls).toEqual([]);
} finally {
agentStepTesting.setDepsForTest();
testState.sessionStorePath = undefined;
await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
},
);
});
describe("sessions_send label lookup", () => {
@@ -30,6 +30,27 @@ export function isTranscriptOnlyOpenClawAssistantMessage(message: unknown): bool
);
}
export function isOpenClawMessageToolMirrorAssistantMessage(message: unknown): boolean {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return false;
}
const entry = message as { role?: unknown; openclawMessageToolMirror?: unknown };
return entry.role === "assistant" && entry.openclawMessageToolMirror !== undefined;
}
export function isOpenClawInternalSourceReplyMirrorAssistantMessage(message: unknown): boolean {
if (!isOpenClawMessageToolMirrorAssistantMessage(message)) {
return false;
}
const marker = (message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror;
return (
Boolean(marker) &&
typeof marker === "object" &&
!Array.isArray(marker) &&
(marker as { sourceReplySink?: unknown }).sourceReplySink === "internal-ui"
);
}
export function isOpenClawDeliveryMirrorAssistantMessage(message: unknown): boolean {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return false;