title: QA channel reconnect dedupe scenario: id: qa-channel-reconnect-dedupe surface: channel coverage: primary: - channels.reconnect secondary: - channels.dedup - runtime.delivery objective: Verify qa-channel readiness polling keeps prior delivery stable and does not replay the last outbound message. successCriteria: - Agent replies once before a reconnect-style readiness cycle. - qa-channel reports ready again without replaying prior outbound delivery. - Follow-up delivery produces one new reply without duplicating the first reply. docsRefs: - docs/channels/qa-channel.md - docs/gateway/configuration.md codeRefs: - extensions/qa-lab/src/qa-channel-transport.ts - extensions/qa-lab/src/bus-state.ts - extensions/qa-lab/src/suite-runtime-gateway.ts execution: kind: flow summary: Verify qa-channel readiness recovery does not duplicate old outbound delivery. config: requiredChannelDriver: qa-channel firstPrompt: "@openclaw Reconnect dedupe setup marker. Reply exactly: RECONNECT-FIRST-OK" secondPrompt: "@openclaw Reconnect dedupe follow-up marker. Reply exactly: RECONNECT-SECOND-OK" firstMarker: RECONNECT-FIRST-OK secondMarker: RECONNECT-SECOND-OK flow: steps: - name: reconnects without replaying prior outbound actions: - call: waitForGatewayHealthy args: - ref: env - 60000 - call: waitForQaChannelReady args: - ref: env - 60000 - call: reset - set: sessionKey value: expr: "`agent:qa:channel-reconnect:${randomUUID().slice(0, 8)}`" - call: runAgentPrompt args: - ref: env - sessionKey: ref: sessionKey to: channel:qa-room message: expr: config.firstPrompt timeoutMs: expr: liveTurnTimeoutMs(env, 45000) - call: waitForOutboundMessage saveAs: firstOutbound args: - ref: state - lambda: params: [candidate] expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound' && String(candidate.text ?? '').includes(config.firstMarker)" - expr: liveTurnTimeoutMs(env, 60000) - set: beforeRestartCursor value: expr: state.getSnapshot().messages.length - call: sleep args: - 1000 - call: waitForQaChannelReady args: - ref: env - 60000 - set: firstMatchesBeforeFollowup value: expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room')" - assert: expr: "firstMatchesBeforeFollowup.length === 1 && String(firstMatchesBeforeFollowup[0]?.text ?? '').includes(config.firstMarker)" message: expr: "`readiness cycle should preserve exactly one marked first reply, saw ${firstMatchesBeforeFollowup.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`" - call: runAgentPrompt args: - ref: env - sessionKey: ref: sessionKey to: channel:qa-room message: expr: config.secondPrompt timeoutMs: expr: liveTurnTimeoutMs(env, 45000) - call: waitForOutboundMessage saveAs: secondOutbound args: - ref: state - lambda: params: [candidate] expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound' && String(candidate.text ?? '').includes(config.secondMarker)" - expr: liveTurnTimeoutMs(env, 60000) - sinceIndex: ref: beforeRestartCursor - set: snapshot value: expr: state.getSnapshot() - set: firstMatches value: expr: "snapshot.messages.slice(0, beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && String(candidate.text ?? '').includes(config.firstMarker))" - set: secondMatches value: expr: "snapshot.messages.slice(beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && String(candidate.text ?? '').includes(config.secondMarker))" - set: postRestartOutbounds value: expr: "snapshot.messages.slice(beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room')" - assert: expr: "firstMatches.length === 1 && secondMatches.length === 1 && postRestartOutbounds.length === 1 && !postRestartOutbounds.some((candidate) => String(candidate.text ?? '').includes(config.firstMarker))" message: expr: "`expected one marked pre-restart reply and exactly one marked post-restart reply without replaying the first marker; first=${firstMatches.length} second=${secondMatches.length} post=${postRestartOutbounds.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`" detailsExpr: "`before=${firstOutbound.text}\\nafter=${secondOutbound.text}`"