diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 1d09b054..1ce57b14 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -1,4 +1,11 @@ -import type { AssistantMessage, ImageContent, Model, Models, UserMessage } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + contentText, + type ImageContent, + type Model, + type Models, + type UserMessage, +} from "@earendil-works/pi-ai"; import { runAgentLoop } from "../agent-loop.ts"; import type { AgentContext, @@ -781,23 +788,10 @@ export class AgentHarness< let newLeafId: string | null; if (targetEntry.type === "message" && targetEntry.message.role === "user") { newLeafId = targetEntry.parentId; - const content = targetEntry.message.content; - editorText = - typeof content === "string" - ? content - : content - .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + editorText = contentText(targetEntry.message.content, ""); } else if (targetEntry.type === "custom_message") { newLeafId = targetEntry.parentId; - editorText = - typeof targetEntry.content === "string" - ? targetEntry.content - : targetEntry.content - .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + editorText = contentText(targetEntry.content, ""); } else { newLeafId = targetId; } diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index fdf1df49..a8d68673 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -1,4 +1,4 @@ -import type { Model, Models } from "@earendil-works/pi-ai"; +import { contentText, type Model, type Models } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; import { @@ -245,10 +245,7 @@ export async function generateBranchSummary( ); } - let summary = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + let summary = contentText(response.content); summary = BRANCH_SUMMARY_PREAMBLE + summary; const { readFiles, modifiedFiles } = computeFileLists(fileOps); summary += formatFileOperations(readFiles, modifiedFiles); diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 4132e3dd..a7f8ebc0 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -1,4 +1,12 @@ -import type { AssistantMessage, ImageContent, Model, Models, TextContent, Usage } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + contentText, + type ImageContent, + type Model, + type Models, + type TextContent, + type Usage, +} from "@earendil-works/pi-ai"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; import { convertToLlm, @@ -513,10 +521,7 @@ export async function generateSummary( ); } - const textContent = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + const textContent = contentText(response.content); return ok(textContent); } @@ -744,10 +749,5 @@ async function generateTurnPrefixSummary( ); } - return ok( - response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"), - ); + return ok(contentText(response.content)); } diff --git a/packages/agent/src/harness/compaction/utils.ts b/packages/agent/src/harness/compaction/utils.ts index 07535b79..7fd3aac0 100644 --- a/packages/agent/src/harness/compaction/utils.ts +++ b/packages/agent/src/harness/compaction/utils.ts @@ -1,4 +1,4 @@ -import type { Message } from "@earendil-works/pi-ai"; +import { contentText, type Message } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; /** File paths touched by a session branch or compaction range. */ @@ -93,23 +93,14 @@ export function serializeConversation(messages: Message[]): string { for (const msg of messages) { if (msg.role === "user") { - const content = - typeof msg.content === "string" - ? msg.content - : msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + const content = contentText(msg.content, ""); if (content) parts.push(`[User]: ${content}`); } else if (msg.role === "assistant") { - const textParts: string[] = []; const thinkingParts: string[] = []; const toolCalls: string[] = []; for (const block of msg.content) { - if (block.type === "text") { - textParts.push(block.text); - } else if (block.type === "thinking") { + if (block.type === "thinking") { thinkingParts.push(block.thinking); } else if (block.type === "toolCall") { const args = block.arguments as Record; @@ -123,17 +114,14 @@ export function serializeConversation(messages: Message[]): string { if (thinkingParts.length > 0) { parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); } - if (textParts.length > 0) { - parts.push(`[Assistant]: ${textParts.join("\n")}`); + if (msg.content.some((block) => block.type === "text")) { + parts.push(`[Assistant]: ${contentText(msg.content)}`); } if (toolCalls.length > 0) { parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); } } else if (msg.role === "toolResult") { - const content = msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + const content = contentText(msg.content, ""); if (content) { parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); } diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7f9388ac..ed86ca72 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `contentText` for extracting joined text from message content. - Added a shared `uuidv7` utility for time-ordered identifiers. ### Fixed diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 1ba45811..4ff67810 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -41,6 +41,7 @@ export * from "./utils/event-stream.ts"; export * from "./utils/json-parse.ts"; export * from "./utils/overflow.ts"; export * from "./utils/retry.ts"; +export { contentText } from "./utils/text.ts"; export * from "./utils/typebox-helpers.ts"; export { uuidv7 } from "./utils/uuid.ts"; export * from "./utils/validation.ts"; diff --git a/packages/ai/src/utils/text.ts b/packages/ai/src/utils/text.ts new file mode 100644 index 00000000..66f5b9c5 --- /dev/null +++ b/packages/ai/src/utils/text.ts @@ -0,0 +1,12 @@ +import type { ImageContent, TextContent, ThinkingContent, ToolCall } from "../types.ts"; + +type Content = TextContent | ImageContent | ThinkingContent | ToolCall; + +/** Extract and join text from message content. */ +export function contentText(content: string | readonly Content[], separator = "\n"): string { + if (typeof content === "string") return content; + return content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(separator); +} diff --git a/packages/ai/test/text.test.ts b/packages/ai/test/text.test.ts new file mode 100644 index 00000000..43dc21c8 --- /dev/null +++ b/packages/ai/test/text.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { type AssistantMessage, contentText, type ToolResultMessage } from "../src/index.ts"; + +const content: AssistantMessage["content"] = [ + { type: "thinking", thinking: "reasoning" }, + { type: "text", text: "first" }, + { type: "toolCall", id: "1", name: "read", arguments: {} }, + { type: "text", text: "second" }, +]; + +describe("contentText", () => { + it("extracts assistant text blocks", () => { + expect(contentText(content)).toBe("first\nsecond"); + }); + + it("supports custom separators", () => { + expect(contentText(content, "")).toBe("firstsecond"); + }); + + it("passes string content through", () => { + expect(contentText("hello")).toBe("hello"); + }); + + it("extracts text from tool-result content", () => { + const toolResultContent: ToolResultMessage["content"] = [ + { type: "text", text: "first" }, + { type: "image", data: "...", mimeType: "image/png" }, + { type: "text", text: "second" }, + ]; + + expect(contentText(toolResultContent, "")).toBe("firstsecond"); + }); +}); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 36e542e5..c4afb9b1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -24,11 +24,11 @@ import type { PrepareNextTurnContext, ThinkingLevel, } from "@earendil-works/pi-agent-core"; +import { contentText } from "@earendil-works/pi-ai"; import type { AssistantMessage, AuthResult, ImageContent, - Message, Model, ProviderHeaders, TextContent, @@ -576,7 +576,7 @@ export class AgentSession { // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { this._overflowRecoveryAttempted = false; - const messageText = this._getUserMessageText(event.message); + const messageText = contentText(event.message.content, ""); if (messageText) { // Check steering queue first const steeringIndex = this._steeringMessages.indexOf(messageText); @@ -659,15 +659,6 @@ export class AgentSession { return false; } - /** Extract text content from a message */ - private _getUserMessageText(message: Message): string { - if (message.role !== "user") return ""; - const content = message.content; - if (typeof content === "string") return content; - const textBlocks = content.filter((c) => c.type === "text"); - return textBlocks.map((c) => (c as TextContent).text).join(""); - } - /** Find the last assistant message in agent state (including aborted ones) */ private _findLastAssistantMessage(): AssistantMessage | undefined { const messages = this.agent.state.messages; @@ -2954,17 +2945,11 @@ export class AgentSession { if (targetEntry.type === "message" && targetEntry.message.role === "user") { // User message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; - editorText = this._extractUserMessageText(targetEntry.message.content); + editorText = contentText(targetEntry.message.content, ""); } else if (targetEntry.type === "custom_message") { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; - editorText = - typeof targetEntry.content === "string" - ? targetEntry.content - : targetEntry.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + editorText = contentText(targetEntry.content, ""); } else { // Non-user message: leaf = selected node newLeafId = targetId; @@ -3032,7 +3017,7 @@ export class AgentSession { if (entry.type !== "message") continue; if (entry.message.role !== "user") continue; - const text = this._extractUserMessageText(entry.message.content); + const text = contentText(entry.message.content, ""); if (text) { result.push({ entryId: entry.id, text }); } @@ -3041,17 +3026,6 @@ export class AgentSession { return result; } - private _extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - } - return ""; - } - /** * Get session statistics. Aggregates over ALL session entries (including * history that was compacted away), so token/cost totals reflect what was diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index a96bb202..f2e06876 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -6,6 +6,7 @@ */ import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; +import { contentText } from "@earendil-works/pi-ai"; import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai/compat"; import { completeSimple } from "@earendil-works/pi-ai/compat"; import { @@ -351,10 +352,7 @@ export async function generateBranchSummary( return { error: response.errorMessage || "Summarization failed" }; } - let summary = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + let summary = contentText(response.content); // Prepend preamble to provide context about the branch summary summary = BRANCH_SUMMARY_PREAMBLE + summary; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 39b1042d..5c71c74d 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,6 +6,7 @@ */ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { contentText } from "@earendil-works/pi-ai"; import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; import { completeSimple } from "@earendil-works/pi-ai/compat"; import { convertToLlm } from "../messages.ts"; @@ -600,10 +601,7 @@ export async function generateSummary( throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); } - const textContent = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + const textContent = contentText(response.content); return textContent; } @@ -865,8 +863,5 @@ async function generateTurnPrefixSummary( throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); } - return response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + return contentText(response.content); } diff --git a/packages/coding-agent/src/core/compaction/utils.ts b/packages/coding-agent/src/core/compaction/utils.ts index 6cfc1622..35d1a29b 100644 --- a/packages/coding-agent/src/core/compaction/utils.ts +++ b/packages/coding-agent/src/core/compaction/utils.ts @@ -3,7 +3,7 @@ */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Message } from "@earendil-works/pi-ai"; +import { contentText, type Message } from "@earendil-works/pi-ai"; // ============================================================================ // File Operation Tracking @@ -111,23 +111,14 @@ export function serializeConversation(messages: Message[]): string { for (const msg of messages) { if (msg.role === "user") { - const content = - typeof msg.content === "string" - ? msg.content - : msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + const content = contentText(msg.content, ""); if (content) parts.push(`[User]: ${content}`); } else if (msg.role === "assistant") { - const textParts: string[] = []; const thinkingParts: string[] = []; const toolCalls: string[] = []; for (const block of msg.content) { - if (block.type === "text") { - textParts.push(block.text); - } else if (block.type === "thinking") { + if (block.type === "thinking") { thinkingParts.push(block.thinking); } else if (block.type === "toolCall") { const args = block.arguments as Record; @@ -141,17 +132,14 @@ export function serializeConversation(messages: Message[]): string { if (thinkingParts.length > 0) { parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); } - if (textParts.length > 0) { - parts.push(`[Assistant]: ${textParts.join("\n")}`); + if (msg.content.some((block) => block.type === "text")) { + parts.push(`[Assistant]: ${contentText(msg.content)}`); } if (toolCalls.length > 0) { parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); } } else if (msg.role === "toolResult") { - const content = msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + const content = contentText(msg.content, ""); if (content) { parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); }