From eeeae78bccb13509a3189f3c13b036defb2f02a9 Mon Sep 17 00:00:00 2001 From: SunnyShu Date: Tue, 28 Jul 2026 06:30:56 +0800 Subject: [PATCH] fix(agents): clean sessions_yield transcript suffix (#113190) * [AI] fix(agents): strip trailing assistant messages after sessions_yield artifact removal * fix(ci): resolve check-lint and check-test-types failures * fix(agents): simplify sessions_yield cleanup (#113190) * fix(agents): separate sessions_yield cleanup budgets (#113190) (thanks @SunnyShu0925) --------- Co-authored-by: Ayaan Zaidi --- .../run/attempt.sessions-yield.test.ts | 204 ++++++++++++++++++ .../run/attempt.sessions-yield.ts | 93 ++++---- 2 files changed, 243 insertions(+), 54 deletions(-) create mode 100644 src/agents/embedded-agent-runner/run/attempt.sessions-yield.test.ts diff --git a/src/agents/embedded-agent-runner/run/attempt.sessions-yield.test.ts b/src/agents/embedded-agent-runner/run/attempt.sessions-yield.test.ts new file mode 100644 index 000000000000..74d6a2eab2db --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt.sessions-yield.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import type { AssistantMessage, ToolResultMessage, UserMessage } from "../../../llm/types.js"; +import type { AgentMessage } from "../../runtime/index.js"; +import { SessionManager } from "../../sessions/session-manager.js"; +import { stripSessionsYieldArtifacts } from "./attempt.sessions-yield.js"; + +const SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE = "openclaw.sessions_yield_interrupt"; + +function makeAssistantMessage(overrides: Partial = {}): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "response" }], + api: "openai-responses", + provider: "openai", + model: "test-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + ...overrides, + }; +} + +function makeToolResultMessage(): ToolResultMessage { + return { + role: "toolResult", + toolCallId: "call-1", + toolName: "sessions_spawn", + content: [{ type: "text", text: "result" }], + isError: false, + timestamp: Date.now(), + }; +} + +function makeUserMessage(): UserMessage { + return { + role: "user", + content: [{ type: "text", text: "continue" }], + timestamp: Date.now(), + }; +} + +function makeYieldInterruptMessage(): AgentMessage { + return { + role: "custom", + customType: SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE, + content: "[sessions_yield interrupt]", + display: false, + details: { source: "sessions_yield" }, + timestamp: Date.now(), + }; +} + +function buildSession(messages: AgentMessage[], sessionManager = SessionManager.inMemory()) { + return { + messages, + agent: { state: { messages: [...messages] } }, + sessionManager, + }; +} + +describe("stripSessionsYieldArtifacts", () => { + it("removes the full non-continuable yield suffix", () => { + const toolResult = makeToolResultMessage(); + const session = buildSession([ + toolResult, + makeAssistantMessage({ content: [{ type: "text", text: "work 1" }] }), + makeAssistantMessage({ content: [{ type: "text", text: "work 2" }] }), + makeAssistantMessage({ stopReason: "aborted" }), + makeYieldInterruptMessage(), + ]); + + stripSessionsYieldArtifacts(session); + + expect(session.agent.state.messages).toEqual([toolResult]); + }); + + it("leaves a continuable suffix unchanged", () => { + const messages = [makeToolResultMessage(), makeUserMessage()]; + const session = buildSession(messages); + + stripSessionsYieldArtifacts(session); + + expect(session.agent.state.messages).toEqual(messages); + }); + + it("strips an assistant tail after synthetic artifacts have already settled", () => { + const toolResult = makeToolResultMessage(); + const session = buildSession([toolResult, makeAssistantMessage()]); + + stripSessionsYieldArtifacts(session); + + expect(session.agent.state.messages).toEqual([toolResult]); + }); + + it("caps persisted assistant cleanup when persistence lacks the interrupt marker", () => { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage(makeToolResultMessage()); + for (let index = 0; index < 4; index += 1) { + sessionManager.appendMessage( + makeAssistantMessage({ content: [{ type: "text", text: `persisted ${index}` }] }), + ); + } + const session = buildSession( + [ + makeToolResultMessage(), + makeAssistantMessage(), + makeAssistantMessage({ stopReason: "aborted" }), + makeYieldInterruptMessage(), + ], + sessionManager, + ); + + stripSessionsYieldArtifacts(session); + + const branch = sessionManager.getBranch(); + expect( + branch.filter((entry) => entry.type === "message" && entry.message.role === "assistant"), + ).toHaveLength(2); + expect( + branch.some( + (entry) => + entry.type === "custom_message" && + entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE, + ), + ).toBe(false); + }); + + it("removes a persisted interrupt marker without consuming the assistant budget", () => { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage(makeToolResultMessage()); + for (let index = 0; index < 3; index += 1) { + sessionManager.appendMessage( + makeAssistantMessage({ content: [{ type: "text", text: `persisted ${index}` }] }), + ); + } + sessionManager.appendCustomMessageEntry( + SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE, + "[sessions_yield interrupt]", + false, + ); + + const session = buildSession( + [makeToolResultMessage(), makeAssistantMessage(), makeAssistantMessage()], + sessionManager, + ); + + stripSessionsYieldArtifacts(session); + + const branch = sessionManager.getBranch(); + expect( + branch.filter((entry) => entry.type === "message" && entry.message.role === "assistant"), + ).toHaveLength(1); + expect( + branch.some( + (entry) => + entry.type === "custom_message" && + entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE, + ), + ).toBe(false); + }); + + it("preserves trailing transcript metadata", () => { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendMessage(makeToolResultMessage()); + sessionManager.appendMessage(makeAssistantMessage({ stopReason: "aborted" })); + sessionManager.appendCustomMessageEntry( + SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE, + "[sessions_yield interrupt]", + false, + ); + sessionManager.appendCustomEntry("plugin-state", { enabled: true }); + + const session = buildSession( + [ + makeToolResultMessage(), + makeAssistantMessage({ stopReason: "aborted" }), + makeYieldInterruptMessage(), + ], + sessionManager, + ); + + stripSessionsYieldArtifacts(session); + + const branch = sessionManager.getBranch(); + expect( + branch.some((entry) => entry.type === "custom" && entry.customType === "plugin-state"), + ).toBe(true); + expect( + branch.some( + (entry) => + (entry.type === "message" && entry.message.role === "assistant") || + (entry.type === "custom_message" && + entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE), + ), + ).toBe(false); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts b/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts index 63e94ed46368..5af2652acddd 100644 --- a/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts +++ b/src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts @@ -1,5 +1,6 @@ import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js"; import type { AgentMessage } from "../../runtime/index.js"; +import type { SessionManager } from "../../sessions/index.js"; /** * Handles sessions-yield interruption, persistence, and artifact cleanup. */ @@ -171,74 +172,58 @@ export async function persistSessionsYieldContextMessage( } // Remove the synthetic yield interrupt + aborted assistant entry from the live transcript. +// After strip, the transcript must end with a non-assistant role so subagent +// completion auto-announce can inject a continuation turn. export function stripSessionsYieldArtifacts(activeSession: { messages: AgentMessage[]; agent: { state: { messages: AgentMessage[] } }; - sessionManager?: unknown; + sessionManager: Pick; }) { + const originalLength = activeSession.messages.length; const strippedMessages = activeSession.messages.slice(); + + // The tool-calling assistant turn and synthetic abort artifacts form one + // non-continuable suffix after sessions_yield. while (strippedMessages.length > 0) { - const last = strippedMessages.at(-1) as - | AgentMessage - | { role?: string; customType?: string; stopReason?: string }; - if (last?.role === "assistant" && "stopReason" in last && last.stopReason === "aborted") { - strippedMessages.pop(); - continue; + const last = strippedMessages.at(-1); + const removable = + last?.role === "assistant" || + (last?.role === "custom" && last.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE); + if (!removable) { + break; } - if ( - last?.role === "custom" && - "customType" in last && - last.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE - ) { - strippedMessages.pop(); - continue; - } - break; - } - if (strippedMessages.length !== activeSession.messages.length) { - activeSession.agent.state.messages = strippedMessages; + strippedMessages.pop(); } - const sessionManager = activeSession.sessionManager as - | { - removeTrailingEntries?: ( - predicate: (entry: { - type?: string; - message?: { - role?: string; - stopReason?: string; - provider?: string; - model?: string; - }; - customType?: string; - }) => boolean, - options?: { - preserveTrailing?: (entry: { - type?: string; - message?: { - role?: string; - provider?: string; - model?: string; - }; - }) => boolean; - }, - ) => number; - } - | undefined; - if (typeof sessionManager?.removeTrailingEntries !== "function") { + const removedMessages = activeSession.messages.slice(strippedMessages.length); + if (removedMessages.length === 0) { return; } - sessionManager.removeTrailingEntries( + activeSession.agent.state.messages = strippedMessages; + + // The interrupt marker can settle independently in live and persisted state. + // Only assistant removals need the live-suffix cap to prevent data loss. + let remainingAssistantCount = removedMessages.filter( + (message) => message.role === "assistant", + ).length; + activeSession.sessionManager.removeTrailingEntries( (entry) => { - const isYieldAbortAssistant = - entry.type === "message" && - entry.message?.role === "assistant" && - entry.message?.stopReason === "aborted"; - const isYieldInterruptMessage = + if ( entry.type === "custom_message" && - entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE; - return isYieldAbortAssistant || isYieldInterruptMessage; + entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE + ) { + return true; + } + if ( + entry.type !== "message" || + entry.message.role !== "assistant" || + remainingAssistantCount === 0 + ) { + return false; + } + remainingAssistantCount -= 1; + return true; }, { preserveTrailing: (entry) =>