Files
openclaw/qa/scenarios/agents/subagent-completion-direct-fallback.yaml
2026-08-19 02:00:11 -07:00

311 lines
21 KiB
YAML

title: Subagent completion terminal-reply delivery
scenario:
id: subagent-completion-direct-fallback
surface: subagents
coverage:
primary:
- agent-runtime.subagent-turns-delivery
secondary:
- agent-runtime.subagent-turns-subagents
- channels.qa-channel-final-reply
objective: Prove visible, silent, empty, restart-recovered, and sanitized direct-fallback subagent completion behavior through real QA channel ingress.
gatewayConfigPatch:
tools:
alsoAllow:
- message
agents:
entries:
qa:
tools:
alsoAllow:
- message
successCriteria:
- Visible completion output reaches the originating QA DM exactly once.
- Exact worker NO_REPLY stays private while required parent completion represents missing child output visibly exactly once.
- Empty output after a side effect settles as a blocked completion while its terminal representation is delivered exactly once.
- Gateway restart does not replay prior terminal payloads or synthesize interruption notices for already-settled handoffs, and a post-restart completion is delivered exactly once.
- Direct fallback strips protected internal metadata before one channel delivery.
docsRefs:
- docs/tools/subagents.md
- docs/help/testing.md
- docs/channels/qa-channel.md
codeRefs:
- src/agents/agent-run-terminal-reply.ts
- src/agents/subagents/announce/subagent-announce-delivery.ts
- src/agents/subagents/registry/subagent-registry-lifecycle.ts
- extensions/qa-lab/src/providers/mock-openai/server.ts
execution:
kind: flow
providerMode: mock-openai
retryCount: 0
summary: Exercise terminal-reply dispositions through mock provider, ephemeral Gateway, SQLite task state, and qa-channel capture.
config:
requiredProviderMode: mock-openai
cases:
- name: visible
marker: QA-SUBAGENT-TERMINAL-VISIBLE-OK
expectedSendCount: 1
- name: silent
marker: QA-SUBAGENT-TERMINAL-SILENT-REPRESENTED
expectedSendCount: 1
- name: fallback
marker: QA-SUBAGENT-TERMINAL-FALLBACK-OK
expectedSendCount: 1
restartMarker: QA-SUBAGENT-TERMINAL-RESTART-OK
metadataSentinel: QA-SUBAGENT-TERMINAL-INTERNAL-MUST-NOT-LEAK
flow:
steps:
- name: proves terminal-reply channel behavior including restart recovery
actions:
- assert:
expr: "env.providerMode === config.requiredProviderMode"
message:
expr: "`expected provider mode ${config.requiredProviderMode}, got ${env.providerMode}`"
- call: waitForGatewayHealthy
args:
- ref: env
- 120000
- call: waitForQaChannelReady
args:
- ref: env
- 120000
- set: verdicts
value:
expr: "[]"
# The task ledger records terminal delivery after the subagent lifecycle
# owner settles it. Use that fact instead of guessing with wall-clock sleeps.
- set: readSettledTerminalTask
value:
lambda:
params:
- caseName
async: true
expr: "(await env.gateway.call('tasks.list', { agentId: 'qa', limit: 100 }, { timeoutMs: 10000 })).tasks?.find((task) => task.title === `qa-terminal-${caseName}` && task.deliveryStatus === 'delivered')"
- forEach:
items:
expr: config.cases
item: terminalCase
actions:
- set: startIndex
value:
expr: state.getSnapshot().messages.length
- set: requestCursor
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor : 0"
- set: conversationId
value:
expr: "`terminal-${terminalCase.name}-${randomUUID().slice(0, 8)}`"
- sendInbound:
accountId: default
conversation:
id:
ref: conversationId
kind: direct
senderId:
ref: conversationId
senderName: QA Terminal Reply Operator
text:
expr: "`Subagent terminal reply QA check: ${terminalCase.name}. Spawn one native worker, then finish the parent turn without waiting. Do not use ACP.`"
- call: waitForCondition
saveAs: terminalTask
args:
- lambda:
async: true
expr: "(async () => { const task = await readSettledTerminalTask(terminalCase.name); if (!task) return undefined; const matchingCount = state.getSnapshot().messages.slice(startIndex).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === conversationId && String(candidate.text ?? '').trim() === terminalCase.marker).length; return terminalCase.expectedSendCount === 0 || matchingCount >= terminalCase.expectedSendCount ? task : undefined; })().catch(() => undefined)"
- 60000
- 250
- set: caseOutbound
value:
expr: "state.getSnapshot().messages.slice(startIndex).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === conversationId)"
- set: matchingOutbound
value:
expr: "caseOutbound.filter((candidate) => String(candidate.text ?? '').trim() === terminalCase.marker)"
- set: caseRequests
value:
expr: "env.mock ? await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursor}`) : []"
- assert:
expr: "matchingOutbound.length === terminalCase.expectedSendCount"
message:
expr: "`terminal ${terminalCase.name}: expected ${terminalCase.expectedSendCount} outbound, got ${matchingOutbound.length}; messages=${JSON.stringify(state.getSnapshot().messages.slice(-10))} requests=${JSON.stringify(caseRequests)} logs=${JSON.stringify((env.gateway.logs?.().split('\\n') ?? []).filter((line) => /qa-channel|subagent|announce|completion|requester/i.test(line)).slice(-30))}`"
- assert:
expr: "caseOutbound.every((candidate) => !String(candidate.text ?? '').includes(config.metadataSentinel) && !String(candidate.text ?? '').includes('BEGIN_OPENCLAW_INTERNAL_CONTEXT'))"
message:
expr: "`terminal ${terminalCase.name}: protected internal metadata leaked; outbound=${recentOutboundSummary(state)}`"
- assert:
expr: "caseOutbound.every((candidate) => String(candidate.text ?? '').trim() !== 'NO_REPLY')"
message:
expr: "`terminal ${terminalCase.name}: exact silence token leaked to the channel; outbound=${recentOutboundSummary(state)}`"
- assert:
expr: "caseOutbound.every((candidate) => !String(candidate.text ?? '').includes(`Agent couldn't generate a response`))"
message:
expr: "`terminal ${terminalCase.name}: unexpected failure diagnostic; outbound=${recentOutboundSummary(state)}`"
- assert:
expr: "caseOutbound.every((candidate) => !String(candidate.text ?? '').includes(`Yield:`))"
message:
expr: "`terminal ${terminalCase.name}: yield raced child completion; outbound=${recentOutboundSummary(state)}`"
- assert:
expr: "caseRequests.some((request) => request.plannedToolName === 'sessions_spawn' && request.plannedToolArgs?.label === `qa-terminal-${terminalCase.name}`)"
message:
expr: "`terminal ${terminalCase.name}: sessions_spawn not observed; requests=${JSON.stringify(caseRequests)}`"
- assert:
expr: "!caseRequests.some((request) => request.plannedToolName === 'sessions_yield')"
message:
expr: "`terminal ${terminalCase.name}: parent did not end before direct fallback; requests=${JSON.stringify(caseRequests)}`"
- assert:
expr: "terminalTask.title === `qa-terminal-${terminalCase.name}` && terminalTask.status === 'completed' && terminalTask.deliveryStatus === 'delivered'"
message:
expr: "`terminal ${terminalCase.name}: task lifecycle did not settle authoritatively; task=${JSON.stringify(terminalTask)}`"
- set: appendVerdict
value:
expr: "verdicts.push({ case: terminalCase.name, conversationId, taskId: terminalTask.taskId, taskDeliveryStatus: terminalTask.deliveryStatus, inputDisposition: terminalCase.name, representation: terminalCase.name === 'silent' ? 'visible representation of private child silence or missing output' : 'exact terminal text', restart: false, fallback: terminalCase.name === 'fallback', expectedTerminalSendCount: terminalCase.expectedSendCount, actualTerminalSendCount: matchingOutbound.length, capturedTerminalPayloads: matchingOutbound.map((message) => String(message.text ?? '')), auxiliaryChannelEvents: caseOutbound.filter((message) => !matchingOutbound.includes(message)).map((message) => String(message.text ?? '')), silenceTokenLeaked: caseOutbound.some((message) => String(message.text ?? '').trim() === 'NO_REPLY'), internalMetadataLeak: caseOutbound.some((message) => String(message.text ?? '').includes(config.metadataSentinel)), pass: true })"
# Every prior task is now terminal and delivery-settled, so restart from
# the lifecycle boundary rather than waiting an arbitrary grace period.
- set: preRestartOutbound
value:
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && verdicts.some((verdict) => verdict.conversationId === message.conversation.id)).map((message) => ({ id: message.id, conversationId: message.conversation.id, text: String(message.text ?? '') }))"
- set: preRestartTerminalPayloads
value:
expr: "preRestartOutbound.filter((message) => verdicts.some((verdict) => verdict.capturedTerminalPayloads.includes(message.text)))"
- set: restartPatch
value:
expr: "({ gateway: { controlUi: { allowedOrigins: [`http://127.0.0.1:${64000 + Math.floor(Math.random() * 999)}`] } } })"
- call: restartGatewayWithConfigPatch
args:
- env:
ref: env
patch:
ref: restartPatch
- call: waitForGatewayHealthy
args:
- ref: env
- 180000
- call: waitForQaChannelReady
args:
- ref: env
- 180000
- set: restartStartIndex
value:
expr: state.getSnapshot().messages.length
- set: restartConversationId
value:
expr: "`terminal-restart-${randomUUID().slice(0, 8)}`"
- sendInbound:
accountId: default
conversation:
id:
ref: restartConversationId
kind: direct
senderId:
ref: restartConversationId
senderName: QA Restart Operator
text: "Subagent terminal reply QA check: restart. Spawn one native worker, then finish the parent turn without waiting. Do not use ACP."
- call: waitForCondition
saveAs: restartTask
args:
- lambda:
async: true
expr: "(async () => { const task = await readSettledTerminalTask('restart'); if (!task) return undefined; const delivered = state.getSnapshot().messages.slice(restartStartIndex).some((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === restartConversationId && String(candidate.text ?? '').trim() === config.restartMarker); return delivered ? task : undefined; })().catch(() => undefined)"
- 60000
- 250
- set: restartMatches
value:
expr: "state.getSnapshot().messages.slice(restartStartIndex).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === restartConversationId && String(candidate.text ?? '').trim() === config.restartMarker)"
- assert:
expr: "restartMatches.length === 1"
message:
expr: "`restart completion expected exactly one outbound, got ${restartMatches.length}; outbound=${recentOutboundSummary(state)}`"
- assert:
expr: "state.getSnapshot().messages.slice(restartStartIndex).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === restartConversationId).every((candidate) => !String(candidate.text ?? '').includes(`Agent couldn't generate a response`))"
message:
expr: "`restart completion produced a failure diagnostic: outbound=${recentOutboundSummary(state)}`"
- assert:
expr: "restartTask.title === 'qa-terminal-restart' && restartTask.status === 'completed' && restartTask.deliveryStatus === 'delivered'"
message:
expr: "`restart completion task lifecycle did not settle authoritatively; task=${JSON.stringify(restartTask)}`"
# Use a new durably delivered task as the observation boundary. By then,
# restart recovery must not have replayed or invented output for settled tasks.
- set: postRestartOutbound
value:
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && verdicts.some((verdict) => verdict.conversationId === message.conversation.id)).map((message) => ({ id: message.id, conversationId: message.conversation.id, text: String(message.text ?? '') }))"
- set: postRestartTerminalPayloads
value:
expr: "postRestartOutbound.filter((message) => verdicts.some((verdict) => verdict.capturedTerminalPayloads.includes(message.text)))"
- set: postRestartUnexpectedPayloads
value:
expr: "postRestartOutbound.filter((message) => !preRestartOutbound.some((before) => before.id === message.id))"
- assert:
expr: "JSON.stringify(postRestartTerminalPayloads) === JSON.stringify(preRestartTerminalPayloads)"
message:
expr: "`Gateway restart replayed or lost a prior terminal payload: before=${JSON.stringify(preRestartTerminalPayloads)} after=${JSON.stringify(postRestartTerminalPayloads)}`"
- assert:
expr: "postRestartUnexpectedPayloads.length === 0"
message:
expr: "`Gateway restart synthesized output for already-settled handoffs: ${JSON.stringify(postRestartUnexpectedPayloads)}`"
- set: appendRestartVerdict
value:
expr: "verdicts.push({ case: 'restart', conversationId: restartConversationId, taskId: restartTask.taskId, taskDeliveryStatus: restartTask.deliveryStatus, inputDisposition: 'visible', restart: true, fallback: true, preRestartTerminalMessageCount: preRestartTerminalPayloads.length, postRestartTerminalPayloadCount: postRestartTerminalPayloads.length, priorTerminalPayloadReplayCount: postRestartTerminalPayloads.length - preRestartTerminalPayloads.length, unexpectedPostRestartPayloadCount: postRestartUnexpectedPayloads.length, unexpectedPostRestartPayloads: postRestartUnexpectedPayloads.map((message) => message.text), expectedTerminalSendCount: 1, actualTerminalSendCount: restartMatches.length, capturedTerminalPayloads: restartMatches.map((message) => String(message.text ?? '')), silenceTokenLeaked: false, internalMetadataLeak: false, pass: true })"
- set: emptyStartIndex
value:
expr: state.getSnapshot().messages.length
- set: emptyRequestCursor
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor : 0"
- set: emptyConversationId
value:
expr: "`terminal-empty-${randomUUID().slice(0, 8)}`"
- sendInbound:
accountId: default
conversation:
id:
ref: emptyConversationId
kind: direct
senderId:
ref: emptyConversationId
senderName: QA Empty Reply Operator
text: "Subagent terminal reply QA check: empty. Spawn one native worker, then finish the parent turn without waiting. Do not use ACP."
# Delivery can settle before the empty completion's execution metadata.
# Wait for every task fact asserted below plus its visible representation.
- call: waitForCondition
saveAs: emptyTask
args:
- lambda:
async: true
expr: "(async () => { const task = await readSettledTerminalTask('empty'); if (!task || task.status !== 'completed' || task.terminalOutcome !== 'blocked' || task.toolUseCount !== 1 || task.lastToolName !== 'write') return undefined; const represented = state.getSnapshot().messages.slice(emptyStartIndex).some((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === emptyConversationId && String(candidate.text ?? '').trim() === 'QA-SUBAGENT-TERMINAL-EMPTY-REPRESENTED'); return represented ? task : undefined; })().catch(() => undefined)"
- 60000
- 250
- set: emptyOutbound
value:
expr: "state.getSnapshot().messages.slice(emptyStartIndex).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === emptyConversationId)"
- set: emptyRepresentation
value:
expr: "emptyOutbound.filter((candidate) => String(candidate.text ?? '').trim() === `QA-SUBAGENT-TERMINAL-EMPTY-REPRESENTED`)"
- set: emptyRequests
value:
expr: "env.mock ? await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${emptyRequestCursor}`) : []"
- assert:
expr: "emptyRepresentation.length === 1"
message:
expr: "`empty completion expected one intentional visible representation, got ${emptyRepresentation.length}; outbound=${recentOutboundSummary(state)} requests=${JSON.stringify(emptyRequests)} logs=${JSON.stringify((env.gateway.logs?.().split('\\n') ?? []).filter((line) => /empty response|incomplete turn|qa-terminal-empty|subagent|announce|completion/i.test(line)).slice(-50))}`"
- assert:
expr: "emptyOutbound.every((candidate) => String(candidate.text ?? '').trim() !== 'NO_REPLY' && !String(candidate.text ?? '').includes(config.metadataSentinel) && !String(candidate.text ?? '').includes('BEGIN_OPENCLAW_INTERNAL_CONTEXT'))"
message:
expr: "`empty completion leaked silence or internal metadata; outbound=${recentOutboundSummary(state)}`"
- assert:
expr: "emptyRequests.some((request) => request.plannedToolName === 'sessions_spawn' && request.plannedToolArgs?.label === 'qa-terminal-empty') && emptyRequests.some((request) => request.plannedToolName === 'write' && request.plannedToolArgs?.path === 'qa-terminal-empty-side-effect.txt') && emptyRequests.some((request) => request.plannedToolName === 'message' && request.plannedToolArgs?.action === 'send' && request.plannedToolArgs?.message === 'QA-SUBAGENT-TERMINAL-EMPTY-REPRESENTED') && !emptyRequests.some((request) => request.plannedToolName === 'sessions_yield')"
message:
expr: "`empty completion did not exercise native spawn/direct-fallback: ${JSON.stringify(emptyRequests)}`"
- assert:
expr: "emptyTask.title === 'qa-terminal-empty' && emptyTask.status === 'completed' && emptyTask.terminalOutcome === 'blocked' && emptyTask.deliveryStatus === 'delivered' && emptyTask.toolUseCount === 1 && emptyTask.lastToolName === 'write'"
message:
expr: "`empty completion execution and delivery did not settle independently; task=${JSON.stringify(emptyTask)}`"
- set: appendEmptyVerdict
value:
expr: "verdicts.push({ case: 'empty', conversationId: emptyConversationId, taskId: emptyTask.taskId, taskStatus: emptyTask.status, taskDeliveryStatus: emptyTask.deliveryStatus, inputDisposition: 'empty-after-side-effect', representation: 'visible ambiguity warning for producer-empty result', restart: false, fallback: false, expectedTerminalSendCount: 1, actualTerminalSendCount: emptyRepresentation.length, capturedTerminalPayloads: emptyRepresentation.map((message) => String(message.text ?? '')), auxiliaryChannelEvents: emptyOutbound.filter((message) => !emptyRepresentation.includes(message)).map((message) => String(message.text ?? '')), silenceTokenLeaked: false, internalMetadataLeak: false, pass: true })"
- assert:
expr: "verdicts.length === 5 && verdicts.every((verdict) => verdict.pass === true)"
message:
expr: "`terminal reply verdict matrix incomplete: ${JSON.stringify(verdicts)}`"
detailsExpr: "JSON.stringify({ harness: 'qa-channel + qa-lab bus + ephemeral Gateway child + mock-openai', verdicts }, null, 2)"