|
| 1 | +title: A2A message-tool mirror dedupe |
| 2 | + |
| 3 | +scenario: |
| 4 | + id: a2a-message-tool-mirror-dedupe |
| 5 | + surface: channel |
| 6 | + coverage: |
| 7 | + primary: |
| 8 | + - runtime.delivery |
| 9 | + secondary: |
| 10 | + - channels.qa-channel |
| 11 | + - tools.message |
| 12 | + 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. |
| 13 | + gatewayConfigPatch: |
| 14 | + messages: |
| 15 | + groupChat: |
| 16 | + visibleReplies: message_tool |
| 17 | + session: |
| 18 | + agentToAgent: |
| 19 | + maxPingPongTurns: 0 |
| 20 | + tools: |
| 21 | + sessions: |
| 22 | + visibility: all |
| 23 | + agentToAgent: |
| 24 | + enabled: true |
| 25 | + successCriteria: |
| 26 | + - Source agent receives a synthetic qa-channel group turn. |
| 27 | + - Source agent calls sessions_send against its real qa-channel group session. |
| 28 | + - Target run calls message(action=send) under the source-reply path. |
| 29 | + - The requester group sees the marker exactly once, with no duplicate during the post-delivery window. |
| 30 | + docsRefs: |
| 31 | + - docs/channels/qa-channel.md |
| 32 | + - docs/concepts/qa-e2e-automation.md |
| 33 | + codeRefs: |
| 34 | + - src/agents/tools/sessions-send-tool.ts |
| 35 | + - src/agents/tools/sessions-send-tool.a2a.ts |
| 36 | + - src/agents/run-wait.ts |
| 37 | + - src/shared/transcript-only-openclaw-assistant.ts |
| 38 | + execution: |
| 39 | + kind: flow |
| 40 | + summary: Run a real QA Gateway/qa-channel A2A source-reply turn and assert the message-tool delivery mirror is not announced again. |
| 41 | + config: |
| 42 | + requiredProviderMode: mock-openai |
| 43 | + requiredChannelDriver: qa-channel |
| 44 | + conversationId: qa-a2a-mirror-room |
| 45 | + conversationTitle: QA A2A Mirror Room |
| 46 | + promptSnippet: qa a2a message-tool mirror check |
| 47 | + targetPromptSnippet: qa group visible reply tool check |
| 48 | + expectedMarker: QA-A2A-MESSAGE-TOOL-MIRROR-OK |
| 49 | + duplicateWindowMs: 8000 |
| 50 | + |
| 51 | +flow: |
| 52 | + steps: |
| 53 | + - name: delivers target message-tool reply once without mirror re-announce |
| 54 | + actions: |
| 55 | + - assert: |
| 56 | + expr: "env.providerMode === config.requiredProviderMode" |
| 57 | + message: this seeded scenario is mock-openai only |
| 58 | + - call: waitForGatewayHealthy |
| 59 | + args: |
| 60 | + - ref: env |
| 61 | + - 60000 |
| 62 | + - call: waitForQaChannelReady |
| 63 | + args: |
| 64 | + - ref: env |
| 65 | + - 60000 |
| 66 | + - call: reset |
| 67 | + - set: startIndex |
| 68 | + value: |
| 69 | + expr: state.getSnapshot().messages.length |
| 70 | + - set: targetSessionKey |
| 71 | + value: |
| 72 | + 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 })" |
| 73 | + - set: requestCountBefore |
| 74 | + value: |
| 75 | + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0" |
| 76 | + - call: state.addInboundMessage |
| 77 | + args: |
| 78 | + - conversation: |
| 79 | + id: |
| 80 | + expr: config.conversationId |
| 81 | + kind: group |
| 82 | + title: |
| 83 | + expr: config.conversationTitle |
| 84 | + senderId: alice |
| 85 | + senderName: Alice |
| 86 | + text: |
| 87 | + expr: "`@openclaw ${config.promptSnippet}. sessionKey=\"${targetSessionKey}\". Use sessions_send once with timeoutSeconds=0. The target must reply visibly with exact marker: \\`${config.expectedMarker}\\``" |
| 88 | + - call: waitForCondition |
| 89 | + saveAs: sourceSessionsSendRequest |
| 90 | + args: |
| 91 | + - lambda: |
| 92 | + async: true |
| 93 | + params: [] |
| 94 | + 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" |
| 95 | + - expr: liveTurnTimeoutMs(env, 60000) |
| 96 | + - 500 |
| 97 | + - call: waitForCondition |
| 98 | + saveAs: targetMessageToolRequest |
| 99 | + args: |
| 100 | + - lambda: |
| 101 | + async: true |
| 102 | + params: [] |
| 103 | + 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" |
| 104 | + - expr: liveTurnTimeoutMs(env, 90000) |
| 105 | + - 500 |
| 106 | + - call: waitForOutboundMessage |
| 107 | + saveAs: outbound |
| 108 | + args: |
| 109 | + - ref: state |
| 110 | + - lambda: |
| 111 | + params: [candidate] |
| 112 | + expr: "candidate.conversation.id === config.conversationId && candidate.conversation.kind === 'group' && candidate.direction === 'outbound' && String(candidate.text ?? '').includes(config.expectedMarker)" |
| 113 | + - expr: liveTurnTimeoutMs(env, 90000) |
| 114 | + - sinceIndex: |
| 115 | + ref: startIndex |
| 116 | + - call: sleep |
| 117 | + args: |
| 118 | + - expr: config.duplicateWindowMs |
| 119 | + - set: snapshot |
| 120 | + value: |
| 121 | + expr: state.getSnapshot() |
| 122 | + - set: matchingOutbound |
| 123 | + value: |
| 124 | + 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))" |
| 125 | + - assert: |
| 126 | + expr: matchingOutbound.length === 1 |
| 127 | + message: |
| 128 | + expr: "`expected exactly one requester-visible A2A marker after duplicate window, saw ${matchingOutbound.length}; transcript=${formatTransportTranscript(state, { conversationId: config.conversationId })}`" |
| 129 | + - set: scenarioRequests |
| 130 | + value: |
| 131 | + 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 })) : []" |
| 132 | + - assert: |
| 133 | + expr: "!env.mock || scenarioRequests.filter((request) => request.plannedToolName === 'sessions_send' && request.plannedToolArgs?.sessionKey === targetSessionKey && request.plannedToolArgs?.timeoutSeconds === 0).length === 1" |
| 134 | + message: |
| 135 | + expr: "`expected exactly one source sessions_send plan for the target session; requests=${JSON.stringify(scenarioRequests)}`" |
| 136 | + - assert: |
| 137 | + expr: "!env.mock || scenarioRequests.filter((request) => request.plannedToolName === 'message' && request.plannedToolArgs?.action === 'send' && request.plannedToolArgs?.message === config.expectedMarker).length === 1" |
| 138 | + message: |
| 139 | + expr: "`expected exactly one target message(action=send) plan for the marker; requests=${JSON.stringify(scenarioRequests)}`" |
| 140 | + detailsExpr: "`outbound=${outbound.conversation.kind}:${outbound.conversation.id}:${outbound.text}; sourceTool=${JSON.stringify(sourceSessionsSendRequest?.plannedToolArgs ?? {})}; targetTool=${JSON.stringify(targetMessageToolRequest?.plannedToolArgs ?? {})}; duplicateWindowMs=${config.duplicateWindowMs}`" |
0 commit comments