mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 19:12:22 +00:00
fix(agents): return string assistant content in getLastAssistantText (#93646)
* fix(agents): handle string assistant content in getLastAssistantText PR #93456 added an `if (!Array.isArray(message.content)) return false` guard to hasAssistantToolCallArguments, acknowledging that a persisted/legacy assistant message can carry a string `content` at runtime even though the type is declared as an array. buildSessionContext pushes such entries through unchanged, so the string can reach agent.state.messages. getLastAssistantText() still assumed an array: iterating a string `content` yields individual characters, none of which has `type === "text"`, so the assistant's text was silently dropped and the function returned undefined. Mirror extractTextContent(): when `content` is a string, treat it as the text itself; otherwise iterate the content blocks as before. The aborted/empty check is left untouched because `.length === 0` is already correct for both an empty array and an empty string. * fix(agents): safely read persisted assistant text --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
@@ -122,6 +122,30 @@ function normalizeBranchSummaryResult(
|
||||
return { error: result.error.message };
|
||||
}
|
||||
|
||||
function hasPersistedAssistantContent(content: unknown): boolean {
|
||||
return (typeof content === "string" || Array.isArray(content)) && content.length > 0;
|
||||
}
|
||||
|
||||
function extractPersistedAssistantText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
let text = "";
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const candidate = block as { type?: unknown; text?: unknown };
|
||||
if (candidate.type === "text" && typeof candidate.text === "string") {
|
||||
text += candidate.text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill Block Parsing
|
||||
// ============================================================================
|
||||
@@ -3213,9 +3237,8 @@ export class AgentSession {
|
||||
if (m.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
const msg = m;
|
||||
// Skip aborted messages with no content
|
||||
if (msg.stopReason === "aborted" && msg.content.length === 0) {
|
||||
const content = (m as { content?: unknown }).content;
|
||||
if (m.stopReason === "aborted" && !hasPersistedAssistantContent(content)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -3225,14 +3248,8 @@ export class AgentSession {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let text = "";
|
||||
for (const content of (lastAssistant as AssistantMessage).content) {
|
||||
if (content.type === "text") {
|
||||
text += content.text;
|
||||
}
|
||||
}
|
||||
|
||||
return text.trim() || undefined;
|
||||
const content = (lastAssistant as { content?: unknown }).content;
|
||||
return extractPersistedAssistantText(content).trim() || undefined;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -110,6 +110,84 @@ async function createSessionAndStreamModel(model: Model): Promise<SimpleStreamOp
|
||||
return streamMocks.streamSimple.mock.lastCall?.[2] ?? {};
|
||||
}
|
||||
|
||||
function appendPersistedAssistantMessage(params: {
|
||||
sessionManager: SessionManager;
|
||||
content: unknown;
|
||||
stopReason?: "stop" | "aborted";
|
||||
}) {
|
||||
params.sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: params.content,
|
||||
api: "messages",
|
||||
provider: "anthropic",
|
||||
model: "sonnet-4.6",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: params.stopReason ?? "stop",
|
||||
timestamp: Date.now(),
|
||||
} as Parameters<SessionManager["appendMessage"]>[0]);
|
||||
}
|
||||
|
||||
async function createSessionFromManager(sessionManager: SessionManager) {
|
||||
const { session } = await createAgentSession({
|
||||
model: testModel,
|
||||
resourceLoader: createEmptyResourceLoader(),
|
||||
sessionManager,
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()),
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
async function createSessionWithPersistedAssistantContent(content: unknown) {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
appendPersistedAssistantMessage({ sessionManager, content });
|
||||
return await createSessionFromManager(sessionManager);
|
||||
}
|
||||
|
||||
describe("AgentSession getLastAssistantText", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "legacy string content",
|
||||
content: " legacy assistant text ",
|
||||
expected: "legacy assistant text",
|
||||
},
|
||||
{
|
||||
name: "normal text blocks",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "hidden" },
|
||||
{ type: "text", text: "visible " },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
expected: "visible answer",
|
||||
},
|
||||
{ name: "null content", content: null, expected: undefined },
|
||||
{ name: "object content", content: { type: "text", text: "malformed" }, expected: undefined },
|
||||
])("reads $name without throwing", async ({ content, expected }) => {
|
||||
const session = await createSessionWithPersistedAssistantContent(content);
|
||||
expect(session.getLastAssistantText()).toBe(expected);
|
||||
});
|
||||
|
||||
it("skips aborted malformed content and returns the preceding assistant text", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
appendPersistedAssistantMessage({ sessionManager, content: "previous answer" });
|
||||
appendPersistedAssistantMessage({
|
||||
sessionManager,
|
||||
content: null,
|
||||
stopReason: "aborted",
|
||||
});
|
||||
const session = await createSessionFromManager(sessionManager);
|
||||
|
||||
expect(session.getLastAssistantText()).toBe("previous answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAgentSession attribution headers", () => {
|
||||
it("tolerates Bedrock models that do not expose baseUrl", async () => {
|
||||
const options = await createSessionAndStreamModel(
|
||||
|
||||
Reference in New Issue
Block a user