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:
Alix-007
2026-06-16 23:57:28 +08:00
committed by GitHub
parent 64785823d0
commit 3630ce6cbb
2 changed files with 106 additions and 11 deletions

View File

@@ -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;
}
// =========================================================================

View File

@@ -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(