diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 292d017eae92..5cdf97eea4c5 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -528,8 +528,9 @@ fallbacks. Fully isolated auth per agent is not supported yet. Sub-agents report back via an announce step: - The announce step runs inside the sub-agent session (not the requester session). -- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted. -- If the latest assistant text is the exact silent token `NO_REPLY` / `no_reply`, announce output is suppressed even if earlier visible progress existed. +- An exact `ANNOUNCE_SKIP` response suppresses announce output. +- For completion-required runs, an exact child `NO_REPLY` response or no output is a missing deliverable handed to the requester/parent for visible representation or retry; it is not credited as silent delivery. +- Optional, duplicate, already-visible, or otherwise non-required paths may use exact `NO_REPLY` for intentional silence. Delivery depends on requester depth: diff --git a/extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts b/extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts index 3299819d3d8b..f413e0310223 100644 --- a/extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts +++ b/extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts @@ -323,6 +323,7 @@ export const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-O export const QA_SUBAGENT_SELF_YIELD_MARKER = "QA-SUBAGENT-SELF-YIELD-FOLLOW-UP-OK"; export const QA_SUBAGENT_TERMINAL_MARKERS = { visible: "QA-SUBAGENT-TERMINAL-VISIBLE-OK", + silent: "QA-SUBAGENT-TERMINAL-SILENT-REPRESENTED", empty: "QA-SUBAGENT-TERMINAL-EMPTY-REPRESENTED", restart: "QA-SUBAGENT-TERMINAL-RESTART-OK", fallback: "QA-SUBAGENT-TERMINAL-FALLBACK-OK", diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index 8f72fd0ffe57..e7abb64fc38f 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -3339,6 +3339,45 @@ Update and merge these partial structured summaries.`, expect(outputText(payload)).toBe("QA-SUBAGENT-TERMINAL-EMPTY-REPRESENTED"); }); + it("delivers silent terminal representation through the required message tool", async () => { + const server = await startMockServer(); + const completionInput = [ + makeUserInput("Subagent terminal reply QA check: silent."), + makeUserInput( + TEST_RUNTIME_CONTEXT_CARRIER.replace( + "runtime metadata", + "[Internal task completion event]\nTask: qa-terminal-silent\nResult: (no output)", + ), + ), + ]; + const delivery = await expectNonStreamingResponsesJson(server, { + tools: [MESSAGE_TOOL], + instructions: + "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. When the message is the completed reply to the current source conversation, set `final=true`.", + input: completionInput, + }); + const messageCall = outputToolCall(delivery, "message"); + expect(outputToolArgsFromItem(messageCall)).toEqual({ + action: "send", + message: "QA-SUBAGENT-TERMINAL-SILENT-REPRESENTED", + final: true, + }); + + const settled = await expectNonStreamingResponsesJson(server, { + tools: [MESSAGE_TOOL], + input: [ + ...completionInput, + messageCall, + makeToolOutputWithCallId( + outputToolCallId(messageCall, "call_mock_message_silent_terminal"), + '{"ok":true,"messageId":"qa-silent-terminal"}', + ), + ], + }); + expect(outputItems(settled).some((item) => item.type === "function_call")).toBe(false); + expect(outputText(settled)).toBe(""); + }); + it.each([ { name: "OpenAI private-source guidance", @@ -3437,9 +3476,14 @@ Update and merge these partial structured summaries.`, }, ); - it.each(["visible", "silent", "fallback", "restart"])( - "uses explicit silence for the %s completion-agent direct fallback", - async (terminalCase) => { + it.each([ + ["visible", "NO_REPLY"], + ["silent", "QA-SUBAGENT-TERMINAL-SILENT-REPRESENTED"], + ["fallback", "NO_REPLY"], + ["restart", "NO_REPLY"], + ])( + "uses the expected representation for the %s completion-agent direct fallback", + async (terminalCase, expected) => { const server = await startMockServer(); const payload = await expectNonStreamingResponsesJson(server, { tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL], @@ -3459,7 +3503,7 @@ Update and merge these partial structured summaries.`, }); expect(outputItems(payload).some((item) => item.type === "function_call")).toBe(false); - expect(outputText(payload)).toBe("NO_REPLY"); + expect(outputText(payload)).toBe(expected); }, ); diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index c406f86a7e01..62b10d6e8c3a 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -1159,7 +1159,13 @@ async function buildResponsesPayload( ?.text.match(QA_SUBAGENT_TERMINAL_MATRIX_PROMPT_RE)?.[1] ?.toLowerCase(); if (terminalCompletionCase && /Internal task completion event/i.test(allInputText)) { - if (terminalCompletionCase === "empty") { + const visibleRepresentation = + terminalCompletionCase === "silent" + ? QA_SUBAGENT_TERMINAL_MARKERS.silent + : terminalCompletionCase === "empty" + ? QA_SUBAGENT_TERMINAL_MARKERS.empty + : undefined; + if (visibleRepresentation) { if (completedToolName === "message") { return buildAssistantEvents(""); } @@ -1174,15 +1180,15 @@ async function buildResponsesPayload( ); return buildToolCallEventsWithArgs("message", { action: "send", - message: QA_SUBAGENT_TERMINAL_MARKERS.empty, + message: visibleRepresentation, ...(requiresFinal ? { final: true } : {}), }); } - return buildAssistantEvents(QA_SUBAGENT_TERMINAL_MARKERS.empty); + return buildAssistantEvents(visibleRepresentation); } - // The direct delivery fallback owns visible, silent, restart, and sanitized - // fallback results. Use explicit silence so generic empty-response recovery - // cannot replay the historical spawn before that fallback runs. + // The direct delivery fallback owns visible, restart, and sanitized fallback + // results. Use explicit silence so generic empty-response recovery cannot + // replay the historical spawn before that fallback runs. return buildAssistantEvents("NO_REPLY"); } const terminalWorkerCase = Array.from( diff --git a/extensions/qa-lab/src/scenario-catalog-channels.test.ts b/extensions/qa-lab/src/scenario-catalog-channels.test.ts index 3074b86bc4af..8c8a67219ebd 100644 --- a/extensions/qa-lab/src/scenario-catalog-channels.test.ts +++ b/extensions/qa-lab/src/scenario-catalog-channels.test.ts @@ -175,7 +175,11 @@ describe("qa scenario catalog channel contracts", () => { marker: "QA-SUBAGENT-TERMINAL-VISIBLE-OK", expectedSendCount: 1, }, - { name: "silent", marker: "NO_REPLY", expectedSendCount: 0 }, + { + name: "silent", + marker: "QA-SUBAGENT-TERMINAL-SILENT-REPRESENTED", + expectedSendCount: 1, + }, { name: "fallback", marker: "QA-SUBAGENT-TERMINAL-FALLBACK-OK", diff --git a/qa/scenarios/agents/subagent-completion-direct-fallback.yaml b/qa/scenarios/agents/subagent-completion-direct-fallback.yaml index 4a35e0b3d7d4..052f625049f1 100644 --- a/qa/scenarios/agents/subagent-completion-direct-fallback.yaml +++ b/qa/scenarios/agents/subagent-completion-direct-fallback.yaml @@ -22,7 +22,7 @@ scenario: - message successCriteria: - Visible completion output reaches the originating QA DM exactly once. - - Exact NO_REPLY completion output produces no channel delivery. + - Exact worker NO_REPLY stays private while required parent completion represents missing child output visibly exactly once. - Genuinely empty completion output is represented intentionally 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. @@ -47,8 +47,8 @@ scenario: marker: QA-SUBAGENT-TERMINAL-VISIBLE-OK expectedSendCount: 1 - name: silent - marker: NO_REPLY - expectedSendCount: 0 + marker: QA-SUBAGENT-TERMINAL-SILENT-REPRESENTED + expectedSendCount: 1 - name: fallback marker: QA-SUBAGENT-TERMINAL-FALLBACK-OK expectedSendCount: 1 @@ -159,7 +159,7 @@ flow: 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' ? 'no terminal channel payload' : '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 })" + 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 diff --git a/src/agents/embedded-agent-runner/delivery-evidence.ts b/src/agents/embedded-agent-runner/delivery-evidence.ts index 9a7b25a5fd3a..e59d69b35220 100644 --- a/src/agents/embedded-agent-runner/delivery-evidence.ts +++ b/src/agents/embedded-agent-runner/delivery-evidence.ts @@ -519,13 +519,22 @@ export function hasVisibleOutboundDeliveryEvidence(result: AgentDeliveryEvidence ); } +/** Returns whether committed non-messaging resource effects make replay unsafe. */ +function hasCommittedNonMessagingOutboundDeliveryEvidence( + result: Pick, +): boolean { + return ( + (Array.isArray(result.acceptedSessionSpawns) && + hasAcceptedSessionSpawn(result.acceptedSessionSpawns)) || + hasPositiveNumber(result.successfulCronAdds) + ); +} + /** Returns whether committed outbound evidence makes replay unsafe. */ export function hasCommittedOutboundDeliveryEvidence(result: AgentDeliveryEvidence): boolean { return ( hasMessagingToolDeliveryEvidence(result) || - (Array.isArray(result.acceptedSessionSpawns) && - hasAcceptedSessionSpawn(result.acceptedSessionSpawns)) || - hasPositiveNumber(result.successfulCronAdds) + hasCommittedNonMessagingOutboundDeliveryEvidence(result) ); } diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 787f6531c475..27ccb40dd658 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -184,6 +184,10 @@ const longChildCompletionOutput = [ "Verification: pnpm test src/agents/subagent-announce-delivery.test.ts passed with the regression enabled.", ].join("\n"); +const committedSessionSpawnEvidence = { + acceptedSessionSpawns: [{ runId: "run-child", childSessionKey: "agent:main:child" }], +} as const; + function registerDirectTargetTestChannel(channelId: string): void { setActivePluginRegistry( createTestRegistry([ @@ -307,6 +311,7 @@ async function deliverDiscordDirectMessageCompletion(params: { internalEvents?: AgentInternalEvent[]; isActive?: boolean; queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome; + sourceSessionKey?: string; sourceTool?: string; signal?: AbortSignal; onDeliveryResult?: Parameters[0]["onDeliveryResult"]; @@ -345,6 +350,7 @@ async function deliverDiscordDirectMessageCompletion(params: { directIdempotencyKey: "announce-dm-fallback-empty", internalEvents: params.internalEvents, sourceRunId: "run-generated-media", + sourceSessionKey: params.sourceSessionKey, sourceTool: params.sourceTool, signal: params.signal, onDeliveryResult: params.onDeliveryResult, @@ -1805,7 +1811,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { name: "accepted session spawn", result: { payloads: [], - acceptedSessionSpawns: [{ runId: "run-child", childSessionKey: "agent:main:child" }], + ...committedSessionSpawnEvidence, }, }, { @@ -2880,6 +2886,78 @@ describe("deliverSubagentAnnouncement completion delivery", () => { expect(sendMessage).not.toHaveBeenCalled(); }); + it.each([ + { + name: "rejects missing visible delivery", + gatewayResult: { payloads: [{ text: "NO_REPLY" }] }, + expected: { + delivered: false, + path: "direct", + reason: "visible_reply_missing", + error: "completion agent did not produce a visible reply", + }, + }, + { + name: "blocks replay after committed outbound side effects", + gatewayResult: { + payloads: [], + ...committedSessionSpawnEvidence, + }, + expected: { + delivered: false, + path: "direct", + reason: "visible_reply_missing", + error: "completion agent did not produce a visible reply", + disposition: "permanent_failure", + phases: [ + { + phase: "direct-primary", + delivered: false, + path: "direct", + error: "completion agent did not produce a visible reply", + }, + ], + }, + }, + { + name: "accepts visible parent output after committed outbound side effects", + gatewayResult: { + payloads: [{ text: "The delegated task completed." }], + ...committedSessionSpawnEvidence, + }, + expected: { delivered: true, path: "direct" }, + }, + ])( + "$name for automatic no-output channel subagent completions", + async ({ gatewayResult, expected }) => { + const callGateway = createGatewayMock({ result: gatewayResult }); + const childSessionKey = "agent:worker:subagent:automatic-no-output"; + const result = await deliverSlackChannelAnnouncement({ + callGateway, + directIdempotencyKey: "announce-channel-subagent-automatic-no-output", + sourceTool: "subagent_announce", + sourceSessionKey: childSessionKey, + internalEvents: taskCompletionEvents({ + childSessionKey, + childSessionId: "child-session-id", + taskLabel: "channel no-output completion smoke", + status: "ok", + statusLabel: "completed successfully", + result: "(no output)", + }), + }); + + expect(result).toMatchObject(expected); + expectGatewayAgentParams(callGateway, { + deliver: true, + channel: "slack", + accountId: "acct-1", + to: "channel:C123", + sourceReplyDeliveryMode: undefined, + }); + }, + ); + it("keeps configured channel subagent completions on parent message-tool handoff", async () => { const callGateway = createGatewayMock({ result: { @@ -2912,6 +2990,76 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }); }); + it.each([ + { + route: "configured Slack channel", + channel: "slack", + accountId: "acct-1", + to: "channel:C123", + }, + { + route: "forced Discord direct message", + channel: "discord", + accountId: "acct-1", + to: "dm:U123", + }, + ] as const)( + "blocks replay for required no-output $route completions with committed outbound side effects", + async ({ route, channel, accountId, to }) => { + const callGateway = createGatewayMock({ + result: { + payloads: [], + ...committedSessionSpawnEvidence, + }, + }); + const sendMessage = createSendMessageMock(); + const childSessionKey = "agent:worker:subagent:no-output-side-effect"; + const internalEvents = taskCompletionEvents({ + childSessionKey, + childSessionId: "child-session-id", + taskLabel: "no-output side-effect smoke", + status: "ok", + statusLabel: "completed successfully", + result: "(no output)", + }); + const result = + route === "configured Slack channel" + ? await deliverSlackChannelAnnouncement({ + callGateway, + sendMessage, + directIdempotencyKey: "announce-channel-subagent-no-output-side-effect", + sourceTool: "subagent_announce", + sourceSessionKey: childSessionKey, + runtimeConfig: { messages: { groupChat: { visibleReplies: "message_tool" } } }, + internalEvents, + }) + : await deliverDiscordDirectMessageCompletion({ + callGateway, + sendMessage, + sourceTool: "subagent_announce", + sourceSessionKey: childSessionKey, + internalEvents, + }); + + expectRecordFields(result, { + delivered: false, + path: "direct", + reason: "visible_reply_missing", + error: "completion agent did not produce a visible reply", + disposition: "permanent_failure", + }); + expectGatewayAgentParams(callGateway, { + deliver: false, + channel, + accountId, + to, + threadId: undefined, + sourceReplyDeliveryMode: "message_tool_only", + }); + expect(sendMessage).not.toHaveBeenCalled(); + }, + ); + it("fails configured channel subagent completions when parent skips required message tool", async () => { const callGateway = createPayloadGatewayMock({ text: "The subagent is done." }); const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(false); @@ -2935,6 +3083,81 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }); }); + it.each([ + { status: "ok", statusLabel: "completed successfully" }, + { status: "error", statusLabel: "failed" }, + ] as const)( + "fails $status no-output channel subagent completions when parent silently skips required message tool", + async ({ status, statusLabel }) => { + const callGateway = createPayloadGatewayMock({ text: "NO_REPLY" }); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(false); + const childSessionKey = "agent:worker:subagent:no-output"; + const result = await deliverSlackChannelAnnouncement({ + callGateway, + directIdempotencyKey: `announce-channel-subagent-${status}-no-output-message-tool-missing`, + sourceTool: "subagent_announce", + sourceSessionKey: childSessionKey, + runtimeConfig: { messages: { groupChat: { visibleReplies: "message_tool" } } }, + queueEmbeddedAgentMessageWithOutcome, + internalEvents: taskCompletionEvents({ + childSessionKey, + childSessionId: "child-session-id", + taskLabel: "channel no-output completion smoke", + status, + statusLabel, + result: "(no output)", + }), + }); + + expectRecordFields(result, { + delivered: false, + path: "direct", + reason: "visible_reply_missing", + error: "completion agent did not produce a visible reply", + }); + expectGatewayAgentParams(callGateway, { + deliver: false, + channel: "slack", + accountId: "acct-1", + to: "channel:C123", + threadId: undefined, + sourceReplyDeliveryMode: "message_tool_only", + }); + }, + ); + + it("preserves intentional silence for no-output channel harness completions", async () => { + const callGateway = createPayloadGatewayMock({ text: "NO_REPLY" }); + const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(false); + const childSessionKey = "agent:worker:subagent:harness-no-output"; + const result = await deliverSlackChannelAnnouncement({ + callGateway, + directIdempotencyKey: "announce-channel-harness-no-output-intentional-silence", + sourceTool: "agent_harness_task", + sourceSessionKey: childSessionKey, + runtimeConfig: { messages: { groupChat: { visibleReplies: "message_tool" } } }, + queueEmbeddedAgentMessageWithOutcome, + internalEvents: taskCompletionEvents({ + childSessionKey, + childSessionId: "child-session-id", + taskLabel: "channel harness no-output completion smoke", + status: "error", + statusLabel: "failed", + result: "(no output)", + }), + }); + + expectDeliveryPath(result, "direct"); + expectGatewayAgentParams(callGateway, { + deliver: false, + channel: "slack", + accountId: "acct-1", + to: "channel:C123", + threadId: undefined, + sourceReplyDeliveryMode: "message_tool_only", + }); + }); + it("does not count a different channel target as the requester completion delivery", async () => { const callGateway = createGatewayMock({ result: { @@ -2969,6 +3192,87 @@ describe("deliverSubagentAnnouncement completion delivery", () => { expect(sendMessage).not.toHaveBeenCalled(); }); + it.each([ + { + name: "rejects off-target messaging alone", + sideEffects: {}, + expected: { + delivered: false, + path: "direct", + reason: "visible_reply_missing", + error: "completion agent did not produce a visible reply", + disposition: "permanent_failure", + }, + }, + { + name: "blocks replay after an accepted session spawn with off-target messaging", + sideEffects: committedSessionSpawnEvidence, + expected: { + delivered: false, + path: "direct", + reason: "visible_reply_missing", + error: "completion agent did not produce a visible reply", + disposition: "permanent_failure", + }, + }, + { + name: "blocks replay after a successful cron add with off-target messaging", + sideEffects: { successfulCronAdds: 1 }, + expected: { + delivered: false, + path: "direct", + reason: "visible_reply_missing", + error: "completion agent did not produce a visible reply", + disposition: "permanent_failure", + }, + }, + ])("$name for required no-output completion", async ({ sideEffects, expected }) => { + const childSessionKey = "agent:worker:subagent:off-target-no-output"; + const callGateway = createGatewayMock({ + result: { + payloads: [], + didSendViaMessagingTool: true, + messagingToolSentTargets: [ + { + tool: "message", + provider: "slack", + accountId: "acct-1", + to: "channel:OTHER", + text: "An unrelated channel update.", + }, + ], + ...sideEffects, + }, + }); + const sendMessage = createSendMessageMock(); + const result = await deliverSlackChannelAnnouncement({ + callGateway, + sendMessage, + directIdempotencyKey: "announce-channel-subagent-off-target-no-output", + sourceTool: "subagent_announce", + sourceSessionKey: childSessionKey, + runtimeConfig: { messages: { groupChat: { visibleReplies: "message_tool" } } }, + internalEvents: taskCompletionEvents({ + childSessionKey, + childSessionId: "child-session-id", + taskLabel: "off-target no-output completion smoke", + status: "ok", + statusLabel: "completed successfully", + result: "(no output)", + }), + }); + + expect(result).toMatchObject(expected); + expectGatewayAgentParams(callGateway, { + deliver: false, + channel: "slack", + accountId: "acct-1", + to: "channel:C123", + sourceReplyDeliveryMode: "message_tool_only", + }); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("delivers Telegram forum-topic subagent completions through the normal parent handoff", async () => { const callGateway = createPayloadGatewayMock({ text: "The delegated task is complete." }); diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 31b3e705728d..1eaef18b5273 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -926,6 +926,11 @@ async function sendSubagentAnnounceDirectly(params: { subagentCompletionEvents[0]?.childSessionKey === params.sourceSessionKey ? subagentCompletionEvents[0] : undefined; + const hasRequiredSubagentNoOutputCompletion = + params.expectsCompletionMessage && + isSubagentCompletion && + (trustedCompletionEvent?.result.trim() === "(no output)" || + hasFailedSubagentNoOutputCompletion(params.internalEvents)); const agentMediatedCompletion = params.expectsCompletionMessage && isAgentMediatedCompletionSourceTool(sourceToolId); const completionRouteRequiresMessageToolDelivery = @@ -1189,21 +1194,34 @@ async function sendSubagentAnnounceDirectly(params: { (hasVisibleAgentPayload(directAnnounceResult, completionPayloadVisibility) || hasMessagingToolDelivery), ); + const hasVisibleNonSilentGatewayPayload = Boolean( + directAnnounceResult && + hasVisibleAgentPayload(directAnnounceResult, { + ...completionPayloadVisibility, + includeSilentReplyPayloads: false, + }), + ); const hasIntentionalSilentCompletionReply = Boolean( directAnnounceResult && hasIntentionalSilentAgentPayload(directAnnounceResult), ); + const hasCompletionSideEffect = Boolean( + directAnnounceResult && hasCommittedOutboundDeliveryEvidence(directAnnounceResult), + ); + const hasVisibleRequiredCompletionReply = + hasMessagingToolDelivery || + (!requiresMessageToolDelivery && hasVisibleNonSilentGatewayPayload); if ( params.expectsCompletionMessage && shouldDeliverAgentFinal && isSubagentCompletion && - !hasVisibleGatewayPayload && + !hasVisibleNonSilentGatewayPayload && !hasMessagingToolDelivery ) { const textDelivery = await tryTextCompletionDirectDelivery(); if (textDelivery) { return textDelivery; } - if (hasFailedSubagentNoOutputCompletion(params.internalEvents)) { + if (hasRequiredSubagentNoOutputCompletion && !hasCompletionSideEffect) { return { delivered: false, path: "direct", @@ -1212,13 +1230,28 @@ async function sendSubagentAnnounceDirectly(params: { }; } } + if ( + hasRequiredSubagentNoOutputCompletion && + !hasVisibleRequiredCompletionReply && + hasCompletionSideEffect + ) { + return { + delivered: false, + path: "direct", + reason: "visible_reply_missing", + error: "completion agent did not produce a visible reply", + disposition: "permanent_failure", + }; + } if ( params.expectsCompletionMessage && requiresMessageToolDelivery && !hasMessagingToolDelivery && - (!hasIntentionalSilentCompletionReply || subagentDirectMessageCompletionRequiresMessageTool) + (!hasIntentionalSilentCompletionReply || + subagentDirectMessageCompletionRequiresMessageTool || + hasRequiredSubagentNoOutputCompletion) ) { - if (hasFailedSubagentNoOutputCompletion(params.internalEvents)) { + if (hasRequiredSubagentNoOutputCompletion) { return { delivered: false, path: "direct", @@ -1268,9 +1301,6 @@ async function sendSubagentAnnounceDirectly(params: { (!params.requireVisibleReply || directAnnounceResult.deliveryStatus?.status !== "suppressed"))), ); - const hasCompletionSideEffect = Boolean( - directAnnounceResult && hasCommittedOutboundDeliveryEvidence(directAnnounceResult), - ); const acceptsIntentionalSilentCompletion = hasIntentionalSilentCompletionReply && !isSubagentCompletion; if ( diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index a834e0a25be7..8120ed746ba5 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -721,6 +721,8 @@ describe("subagent announce formatting", () => { inputTokens: 12, outputTokens: 1000, totalTokens: 197000, + totalTokensFresh: true, + totalTokensVersion: 1, }, }; readLatestAssistantReplyMock.mockResolvedValue( @@ -897,7 +899,7 @@ describe("subagent announce formatting", () => { expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1); }); - it("suppresses completion delivery when subagent reply is NO_REPLY", async () => { + it("hands required NO_REPLY completion to the parent as missing output", async () => { const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", childRunId: "run-direct-completion-no-reply", @@ -909,6 +911,24 @@ describe("subagent announce formatting", () => { roundOneReply: " NO_REPLY ", }); + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).toHaveBeenCalledTimes(1); + expect(getAgentCall()?.params?.message).toContain("(no output)"); + }); + + it("keeps non-required NO_REPLY completion intentionally silent", async () => { + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-non-required-completion-no-reply", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "slack", to: "channel:C123", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: false, + roundOneReply: " NO_REPLY ", + }); + expect(didAnnounce).toBe(true); expect(sendSpy).not.toHaveBeenCalled(); expect(agentSpy).not.toHaveBeenCalled(); @@ -924,8 +944,8 @@ describe("subagent announce formatting", () => { { name: "silent", terminalReply: { disposition: "silent" } as const, - expectedAgentCalls: 0, - expectedMessage: undefined, + expectedAgentCalls: 1, + expectedMessage: "(no output)", }, { name: "empty", diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 5476c87609ba..08570716d006 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -461,9 +461,22 @@ export async function runSubagentAnnounceFlow(params: { } } + const fallbackReply = failedTerminalOutcome + ? undefined + : normalizeOptionalString(params.fallbackReply); + const hasVisibleFallback = + Boolean(fallbackReply) && + !(isAnnounceSkip(fallbackReply) || isSilentReplyText(fallbackReply, SILENT_REPLY_TOKEN)); + const cleanedFallbackReply = hasVisibleFallback + ? (stripAndClassifyReply(fallbackReply ?? "") ?? undefined) + : undefined; + if (!childCompletionFindings) { if (params.terminalReply?.disposition === "silent") { - return true; + if (!hasVisibleFallback && (isAnnounceSkip(fallbackReply) || !expectsCompletionMessage)) { + return true; + } + reply = cleanedFallbackReply; } if (params.terminalReply?.disposition === "empty" && outcome.status === "timeout") { const timeoutProgress = await readSubagentTimeoutProgress( @@ -478,13 +491,6 @@ export async function runSubagentAnnounceFlow(params: { } } if (!params.terminalReply) { - const fallbackReply = failedTerminalOutcome - ? undefined - : normalizeOptionalString(params.fallbackReply); - const fallbackIsSilent = - Boolean(fallbackReply) && - (isAnnounceSkip(fallbackReply) || isSilentReplyText(fallbackReply, SILENT_REPLY_TOKEN)); - if (childSessionEffectsAllowed() && !reply && allowFailedOutputCapture) { reply = await readSubagentOutput(params.childSessionKey, outcome); } @@ -497,7 +503,7 @@ export async function runSubagentAnnounceFlow(params: { }); } - if (!reply?.trim() && fallbackReply && !fallbackIsSilent) { + if (!reply?.trim() && hasVisibleFallback) { reply = fallbackReply; } @@ -523,44 +529,32 @@ export async function runSubagentAnnounceFlow(params: { } } - if (isAnnounceSkip(reply) || isSilentReplyText(reply, SILENT_REPLY_TOKEN)) { - if (fallbackReply && !fallbackIsSilent) { - const cleaned = stripAndClassifyReply(fallbackReply); - if (cleaned === null) { - if (isAnnounceSkip(reply) && isCronSessionKey(targetRequesterSessionKey)) { - logWarn( - `cron job completion for session=${targetRequesterSessionKey} ` + - `run=${params.childRunId} suppressed by ANNOUNCE_SKIP; ` + - `the agent replied with the skip sentinel instead of delivering a result`, - ); - } - return true; - } - reply = cleaned; + const replyIsAnnounceSkip = isAnnounceSkip(reply); + if (replyIsAnnounceSkip || isSilentReplyText(reply, SILENT_REPLY_TOKEN)) { + if (hasVisibleFallback && cleanedFallbackReply) { + reply = cleanedFallbackReply; } else { - if (isAnnounceSkip(reply) && isCronSessionKey(targetRequesterSessionKey)) { + if (replyIsAnnounceSkip && isCronSessionKey(targetRequesterSessionKey)) { logWarn( `cron job completion for session=${targetRequesterSessionKey} ` + `run=${params.childRunId} suppressed by ANNOUNCE_SKIP; ` + `the agent replied with the skip sentinel instead of delivering a result`, ); } - return true; - } - } else if (reply) { - const cleaned = stripAndClassifyReply(reply); - if (cleaned === null) { - if (fallbackReply && !fallbackIsSilent) { - const cleanedFallback = stripAndClassifyReply(fallbackReply); - if (cleanedFallback === null) { - return true; - } - reply = cleanedFallback; - } else { + if ( + replyIsAnnounceSkip || + isAnnounceSkip(fallbackReply) || + !expectsCompletionMessage || + hasVisibleFallback + ) { return true; } - } else { - reply = cleaned; + reply = undefined; + } + } else if (reply) { + reply = stripAndClassifyReply(reply) ?? cleanedFallbackReply; + if (!reply) { + return true; } } } @@ -573,6 +567,13 @@ export async function runSubagentAnnounceFlow(params: { if (!childSessionEffectsAllowed()) { childCompletionFindings = undefined; reply = params.roundOneReply ?? params.fallbackReply; + if ( + expectsCompletionMessage && + (params.terminalReply?.disposition === "silent" || + isSilentReplyText(reply, SILENT_REPLY_TOKEN)) + ) { + reply = hasVisibleFallback ? cleanedFallbackReply : undefined; + } outcome = params.outcome ?? { status: "unknown" }; } diff --git a/src/agents/tools/sessions-tool.test.ts b/src/agents/tools/sessions-tool.test.ts index 2f9075d7831b..21917dc5c4cd 100644 --- a/src/agents/tools/sessions-tool.test.ts +++ b/src/agents/tools/sessions-tool.test.ts @@ -9,11 +9,79 @@ import { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isAgentSessionModelPatchOrigin } from "../../gateway/session-model-patch-origin.js"; import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../../security/dangerous-tools.js"; +import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; import { createAgentPatchedSessionModelRunGuard } from "../session-model-auto-revert.js"; import { testing as sessionsResolutionTesting } from "./sessions-resolution.test-support.js"; import { createSessionsTool } from "./sessions-tool.js"; +const overlongUnicode = (unit: string, maxLength: number) => `${unit.repeat(maxLength - 1)}๐Ÿฆžtail`; + +const adversarialResolved = { + modelProvider: overlongUnicode("็•Œ", 48), + model: overlongUnicode("ๆจก", 96), + agentRuntime: { + id: overlongUnicode("้‹", 48), + fallback: "openclaw" as const, + source: "session-key" as const, + }, + thinkingLevel: overlongUnicode("่€ƒ", 16), + thinkingLevels: Array.from({ length: 12 }, (_, index) => ({ + id: `${index}:${overlongUnicode("่ญ˜", 12)}`, + label: `${index}:${overlongUnicode("ๆ€", 16)}`, + })), +}; + +const escapedControlText = "\0".repeat(10_000); +const escapeHeavyResolved = { + modelProvider: escapedControlText, + model: escapedControlText, + agentRuntime: { + id: escapedControlText, + fallback: "none" as const, + source: "provider" as const, + }, + thinkingLevel: escapedControlText, + thinkingLevels: Array.from({ length: 12 }, (_, index) => ({ + id: `${index}:${escapedControlText}`, + label: `${index}:${escapedControlText}`, + })), +}; + +const expectedResolvedOmission = { + reason: "response_budget_exceeded", +} as const; + +function expectExactResolvedAcknowledgement( + result: { + content: Array<{ type: string; text?: string }>; + details: unknown; + }, + expectedResolved: unknown, +) { + expect((result.details as { resolved?: unknown }).resolved).toEqual(expectedResolved); + const text = result.content[0]?.text ?? ""; + expect(JSON.parse(text)).toEqual(result.details); + expect(text).not.toContain('"entry"'); + expect(text).not.toContain('"path"'); + expect(text).not.toContain("skillsSnapshot"); + expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(3_840); +} + +function expectOmittedResolvedAcknowledgement(result: { + content: Array<{ type: string; text?: string }>; + details: unknown; +}) { + expect(result.details).toMatchObject({ resolvedOmitted: expectedResolvedOmission }); + expect((result.details as { resolved?: unknown }).resolved).toBeUndefined(); + const text = result.content[0]?.text ?? ""; + expect(JSON.parse(text)).toEqual(result.details); + expect(text).not.toContain('"entry"'); + expect(text).not.toContain('"path"'); + expect(text).not.toContain("skillsSnapshot"); + expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(3_840); +} + describe("sessions tool", () => { afterEach(() => { sessionsResolutionTesting.setDepsForTest(); @@ -560,6 +628,250 @@ describe("sessions tool", () => { ]); }); + it("returns a bounded acknowledgement instead of the patched session entry", async () => { + const callGateway = vi.fn(async () => ({ + ok: true, + path: `/sessions/${"p".repeat(10_000)}`, + key: "agent:main:main", + entry: { + skillsSnapshot: "s".repeat(47_469), + sessionDiffBaseline: "b".repeat(3_665), + }, + resolved: { + modelProvider: "openai", + model: "gpt-5.6-luna", + }, + })); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: {}, + callGateway: callGateway as never, + }); + + const result = await tool.execute("patch-sidebar", { + action: "patch", + label: "Movies", + icon: "name:film", + }); + + expect(callGateway).toHaveBeenCalledWith("sessions.patch", { + key: "agent:main:main", + label: "Movies", + icon: "name:film", + }); + expect(result.details).toEqual({ + status: "updated", + sessionKey: "agent:main:main", + updated: ["label", "icon"], + }); + const text = (result.content[0] as { text?: string } | undefined)?.text ?? ""; + expect(text).not.toContain('"entry"'); + expect(text).not.toContain('"path"'); + expect(text).not.toContain('"resolved"'); + expect(text).not.toContain("skillsSnapshot"); + expect(text).not.toContain("sessionDiffBaseline"); + expect(Buffer.byteLength(text, "utf8")).toBeLessThan(512); + }); + + it("returns authoritative resolved model and thinking metadata without the patched entry", async () => { + const resolved = { + modelProvider: "openai", + model: "gpt-5.6-luna", + agentRuntime: { id: "codex", fallback: "openclaw" as const, source: "session" as const }, + thinkingLevel: "medium", + thinkingLevels: [ + { id: "off", label: "Off" }, + { id: "medium", label: "Medium" }, + ], + }; + const callGateway = vi.fn(async () => ({ + ok: true as const, + path: `/sessions/${"p".repeat(10_000)}`, + key: "agent:main:main", + entry: { skillsSnapshot: "s".repeat(47_469) }, + resolved, + })); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: {}, + callGateway: callGateway as never, + }); + + const result = await tool.execute("patch-model-thinking", { + action: "patch", + model: "openai/luna", + thinkingLevel: "med", + }); + + expect(result.details).toEqual({ + status: "updated", + sessionKey: "agent:main:main", + updated: ["model", "thinkingLevel"], + resolved, + }); + const text = (result.content[0] as { text?: string } | undefined)?.text ?? ""; + expect(text).not.toContain('"entry"'); + expect(text).not.toContain('"path"'); + expect(text).not.toContain("skillsSnapshot"); + expect(Buffer.byteLength(text, "utf8")).toBeLessThan(1_024); + }); + + it("preserves the complete canonical thinking catalog through ultra", async () => { + const thinkingLevels = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "adaptive", + "max", + "ultra", + ].map((id) => ({ id, label: id })); + const callGateway = vi.fn(async () => ({ + ok: true as const, + path: "/sessions/main", + key: "agent:main:main", + entry: {}, + resolved: { thinkingLevel: "ultra", thinkingLevels }, + })); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: {}, + callGateway: callGateway as never, + }); + + const result = await tool.execute("patch-ultra-thinking", { + action: "patch", + thinkingLevel: "ultra", + }); + + expect(result.details).toMatchObject({ + resolved: { thinkingLevel: "ultra", thinkingLevels }, + }); + }); + + it("preserves long resolved identifiers and complete catalogs exactly when they fit", async () => { + const callGateway = vi.fn(async () => ({ + ok: true as const, + path: `/sessions/${"p".repeat(10_000)}`, + key: "agent:main:main", + entry: { skillsSnapshot: "s".repeat(47_469) }, + resolved: adversarialResolved, + })); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: {}, + callGateway: callGateway as never, + }); + + const result = await tool.execute("patch-adversarial-model-thinking", { + action: "patch", + model: "openai/luna", + thinkingLevel: "med", + }); + + expect(result.details).toMatchObject({ + status: "updated", + sessionKey: "agent:main:main", + updated: ["model", "thinkingLevel"], + }); + expectExactResolvedAcknowledgement(result, adversarialResolved); + }); + + it("omits oversized resolved metadata instead of changing authoritative identifiers", async () => { + const callGateway = vi.fn(async () => ({ + ok: true as const, + path: `/sessions/${"p".repeat(10_000)}`, + key: "agent:main:main", + entry: { skillsSnapshot: "s".repeat(47_469) }, + resolved: escapeHeavyResolved, + })); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: {}, + callGateway: callGateway as never, + }); + + const result = await tool.execute("patch-oversized-model-thinking", { + action: "patch", + model: "openai/luna", + thinkingLevel: "med", + }); + + expect(result.details).toMatchObject({ + status: "updated", + sessionKey: "agent:main:main", + updated: ["model", "thinkingLevel"], + resolvedOmitted: expectedResolvedOmission, + }); + expectOmittedResolvedAcknowledgement(result); + }); + + it("keeps resolved model and thinking metadata when self-archive is deferred", async () => { + await withTempDir({ prefix: "openclaw-sessions-tool-archive-" }, async (dir) => { + const storePath = path.join(dir, "sessions.json"); + const sessionKey = "agent:main:subagent:archive-me"; + const sessionId = "archive-me-session"; + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { sessionId, updatedAt: 1 }, + ); + const callGateway = vi.fn(async () => ({ + ok: true as const, + path: storePath, + key: sessionKey, + entry: { skillsSnapshot: "s".repeat(47_469) }, + resolved: adversarialResolved, + })); + const tool = createSessionsTool({ + agentSessionKey: sessionKey, + config: { session: { store: storePath } }, + callGateway: callGateway as never, + }); + const admission = await beginSessionWorkAdmission({ + scope: storePath, + identities: [sessionKey, sessionId], + assertAllowed: () => {}, + }); + + try { + const result = await admission.run( + async () => + await tool.execute("patch-model-thinking-archive", { + action: "patch", + archived: true, + model: "openai/luna", + thinkingLevel: "med", + }), + ); + expect(result.details).toEqual({ + status: "scheduled", + sessionKey, + message: "Session will be archived after the current agent run finishes.", + resolved: adversarialResolved, + }); + expectExactResolvedAcknowledgement(result, adversarialResolved); + expect(callGateway).toHaveBeenCalledTimes(1); + } finally { + admission.release(); + } + + await vi.waitFor(() => expect(callGateway).toHaveBeenCalledTimes(2)); + expect(callGateway).toHaveBeenNthCalledWith(1, "sessions.patch", { + key: sessionKey, + model: "openai/luna", + thinkingLevel: "med", + expectedSessionId: sessionId, + }); + expect(callGateway).toHaveBeenNthCalledWith(2, "sessions.patch", { + key: sessionKey, + archived: true, + expectedSessionId: sessionId, + }); + }); + }); + it("patches and clears title, status, attention, and archive state", async () => { const callGateway = vi.fn(async () => ({ ok: true })); const tool = createSessionsTool({ diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index d3ac206f52ca..b5ea8a93f5cd 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -1,6 +1,7 @@ /** Session self-service tool. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { Type } from "typebox"; +import type { SessionsPatchResult } from "../../../packages/gateway-protocol/src/index.js"; import { SESSION_AGENT_ATTENTION_ICON_IDS } from "../../../packages/gateway-protocol/src/session-icon.js"; import { getRuntimeConfig } from "../../config/config.js"; import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; @@ -10,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { GatewayTransportError } from "../../gateway/call.js"; import { withAgentSessionModelPatchOrigin } from "../../gateway/session-model-patch-origin.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { boundedJsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; import { isTransientNetworkError } from "../../infra/unhandled-rejections.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; @@ -47,8 +49,39 @@ const ACTIONS = [ const GROUP_NAME_MAX_LENGTH = 512; const GROUP_NAMES_MAX_ITEMS = 200; const SELF_ARCHIVE_MAX_RETRY_DELAY_MS = 5_000; +const SESSIONS_TOOL_RESULT_MAX_BYTES = 3_840; +const RESOLVED_OMITTED_REASON = "response_budget_exceeded"; const log = createSubsystemLogger("agents/sessions"); +type SessionsResolved = NonNullable; + +function sessionsToolResultFitsBudget(payload: Record): boolean { + const compactSize = boundedJsonUtf8Bytes(payload, SESSIONS_TOOL_RESULT_MAX_BYTES); + if (!compactSize.complete || compactSize.bytes > SESSIONS_TOOL_RESULT_MAX_BYTES) { + return false; + } + return ( + Buffer.byteLength(JSON.stringify(payload, null, 2), "utf8") <= SESSIONS_TOOL_RESULT_MAX_BYTES + ); +} + +function withBoundedSessionsResolved( + acknowledgement: Record, + resolved: SessionsResolved | undefined, +): Record { + if (!resolved) { + return acknowledgement; + } + const completeResult = { ...acknowledgement, resolved }; + if (sessionsToolResultFitsBudget(completeResult)) { + return completeResult; + } + return { + ...acknowledgement, + resolvedOmitted: { reason: RESOLVED_OMITTED_REASON }, + }; +} + const SessionsToolSchema = Type.Object( { action: stringEnum(ACTIONS, { description: "Action" }), @@ -327,12 +360,13 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool error: "Model patch needs in-process gateway.", }); } - const callSessionPatch = async (sessionPatch: typeof patch) => + const callSessionPatch = async (sessionPatch: typeof patch): Promise => sessionPatch.model === undefined - ? await gatewayCall("sessions.patch", sessionPatch) + ? await gatewayCall("sessions.patch", sessionPatch) : await withAgentSessionModelPatchOrigin( - async () => await gatewayCall("sessions.patch", sessionPatch), + async () => await gatewayCall("sessions.patch", sessionPatch), ); + const includeResolved = patch.model !== undefined || patch.thinkingLevel !== undefined; if (patch.archived === true && key === requesterKey && key !== "global") { const agentId = resolveAgentIdFromSessionKey(key, resolveDefaultAgentId(cfg)); @@ -352,8 +386,12 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool : {}), }; const { archived: _archived, ...immediatePatch } = patch; + let immediateResult: SessionsPatchResult | undefined; if (Object.keys(immediatePatch).length > 1) { - await callSessionPatch({ ...immediatePatch, ...expectedSessionIdentity }); + immediateResult = await callSessionPatch({ + ...immediatePatch, + ...expectedSessionIdentity, + }); } // Archive only after the final tool result, transcript, and every @@ -442,17 +480,31 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool log.warn(`deferred self-archive failed for ${key}: ${formatErrorMessage(error)}`); }); - return jsonResult({ - status: "scheduled", - sessionKey: key, - message: "Session will be archived after the current agent run finishes.", - }); + return jsonResult( + withBoundedSessionsResolved( + { + status: "scheduled", + sessionKey: key, + message: "Session will be archived after the current agent run finishes.", + }, + includeResolved ? immediateResult?.resolved : undefined, + ), + ); } } } const result = await callSessionPatch(patch); - return jsonResult(result); + return jsonResult( + withBoundedSessionsResolved( + { + status: "updated", + sessionKey: key, + updated: Object.keys(patch).filter((field) => field !== "key"), + }, + includeResolved ? result.resolved : undefined, + ), + ); }, }; }