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 <hi@obviy.us>
This commit is contained in:
SunnyShu
2026-07-28 06:30:56 +08:00
committed by GitHub
parent e5e77b656a
commit eeeae78bcc
2 changed files with 243 additions and 54 deletions
@@ -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> = {}): 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);
});
});
@@ -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<SessionManager, "removeTrailingEntries">;
}) {
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) =>