diff --git a/CHANGELOG.md b/CHANGELOG.md index 54abd98f..2ff3e529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### Fixed + +- **AG-UI recorder** — `extractLastUserMessage` now walks structured `content` + arrays (e.g. `[{ type: "text", text: "..." }, { type: "document", source: ... }]`) + and joins their text parts. Previously, structured content fell back to the + `__NO_USER_MESSAGE__` sentinel, producing fixtures that couldn't replay. + +### Changed + +- **`AGUIMessage.content` type widened** to `string | AGUIMessageContentPart[]`. + New exported type `AGUIMessageContentPart` describes the per-part shape. + ## [1.26.1] - 2026-05-19 ### Added diff --git a/src/__tests__/agui-mock.test.ts b/src/__tests__/agui-mock.test.ts index 2d8081ab..697eec75 100644 --- a/src/__tests__/agui-mock.test.ts +++ b/src/__tests__/agui-mock.test.ts @@ -17,7 +17,9 @@ import { buildStepWithText, buildCompositeResponse, buildTextChunkResponse, + extractLastUserMessage, } from "../agui-handler.js"; +import { NO_USER_MESSAGE_SENTINEL } from "../agui-recorder.js"; import { LLMock } from "../llmock.js"; import { Journal } from "../journal.js"; @@ -998,3 +1000,256 @@ describe("AGUIMock record & replay", () => { expect(types).toContain("RUN_FINISHED"); }); }); + +// --------------------------------------------------------------------------- +// extractLastUserMessage — string and structured content +// --------------------------------------------------------------------------- + +describe("extractLastUserMessage", () => { + it("returns plain string content verbatim", () => { + expect( + extractLastUserMessage({ + messages: [{ id: "1", role: "user", content: "hello" }], + }), + ).toBe("hello"); + }); + + it("returns text from a single-part array", () => { + expect( + extractLastUserMessage({ + messages: [{ id: "1", role: "user", content: [{ type: "text", text: "hello" }] }], + }), + ).toBe("hello"); + }); + + it("joins multiple text parts with a single space", () => { + expect( + extractLastUserMessage({ + messages: [ + { + id: "1", + role: "user", + content: [ + { type: "text", text: "part one" }, + { type: "text", text: "part two" }, + ], + }, + ], + }), + ).toBe("part one part two"); + }); + + it("extracts only text parts when mixed with non-text parts (e.g. file attachments)", () => { + expect( + extractLastUserMessage({ + messages: [ + { + id: "1", + role: "user", + content: [ + { type: "text", text: "summarize this" }, + { + type: "document", + source: { type: "data", value: "AAA=", mimeType: "text/plain" }, + }, + ], + }, + ], + }), + ).toBe("summarize this"); + }); + + it("returns empty string when content has no text parts", () => { + expect( + extractLastUserMessage({ + messages: [ + { + id: "1", + role: "user", + content: [ + { + type: "document", + source: { type: "data", value: "AAA=", mimeType: "text/plain" }, + }, + ], + }, + ], + }), + ).toBe(""); + }); + + it("ignores non-text parts that happen to carry a 'text' field", () => { + expect( + extractLastUserMessage({ + messages: [ + { + id: "1", + role: "user", + content: [{ type: "image", text: "alt text not part of the message" }], + }, + ], + }), + ).toBe(""); + }); + + it("returns the last user turn's text when multiple user turns exist", () => { + expect( + extractLastUserMessage({ + messages: [ + { id: "1", role: "user", content: "first" }, + { id: "2", role: "assistant", content: "ack" }, + { id: "3", role: "user", content: [{ type: "text", text: "second" }] }, + ], + }), + ).toBe("second"); + }); + + it("skips non-user roles even when they have text content", () => { + expect( + extractLastUserMessage({ + messages: [ + { id: "1", role: "user", content: "real user message" }, + { id: "2", role: "assistant", content: "assistant turn" }, + ], + }), + ).toBe("real user message"); + }); + + it("returns empty string for empty or missing messages", () => { + expect(extractLastUserMessage({ messages: [] })).toBe(""); + expect(extractLastUserMessage({} as AGUIRunAgentInput)).toBe(""); + }); + + it("returns empty string when user message content is undefined", () => { + expect( + extractLastUserMessage({ + messages: [{ id: "1", role: "user" }], + }), + ).toBe(""); + }); + + it("falls through structured-only user message to find earlier user message with text", () => { + expect( + extractLastUserMessage({ + threadId: "t1", + runId: "r1", + messages: [ + { id: "m1", role: "user", content: "earlier question" }, + { id: "m2", role: "assistant", content: "response" }, + { + id: "m3", + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/photo.jpg" } }], + }, + ], + }), + ).toBe("earlier question"); + }); +}); + +// --------------------------------------------------------------------------- +// Recorder regression — structured user content produces a matchable fixture +// --------------------------------------------------------------------------- + +describe("AGUIMock recorder — structured user content", () => { + let upstream: AGUIMock | null = null; + let tmpDir = ""; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agui-structured-")); + }); + + afterEach(async () => { + if (upstream) { + try { + await upstream.stop(); + } catch { + /* already stopped */ + } + upstream = null; + } + if (tmpDir) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + }); + + it("writes match.message from text parts, not the sentinel, when content is structured", async () => { + upstream = new AGUIMock({ port: 0 }); + upstream.onPredicate(() => true, buildTextResponse("ok")); + const upstreamUrl = await upstream.start(); + + agui = new AGUIMock({ port: 0 }); + agui.enableRecording({ upstream: upstreamUrl, proxyOnly: false, fixturePath: tmpDir }); + await agui.start(); + + const resp = await post(agui.url, { + messages: [ + { + id: "u1", + role: "user", + content: [ + { type: "text", text: "summarize this" }, + { + type: "document", + source: { type: "data", value: "AAA=", mimeType: "text/plain" }, + }, + ], + }, + ], + } as AGUIRunAgentInput); + expect(resp.status).toBe(200); + + const files = fs.readdirSync(tmpDir); + expect(files.length).toBe(1); + const parsed = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")); + expect(parsed.fixtures[0].match.message).toBe("summarize this"); + expect(parsed.fixtures[0].match.message).not.toBe(NO_USER_MESSAGE_SENTINEL); + }); + + it("still writes the sentinel when no user text is present (e.g. only file parts)", async () => { + upstream = new AGUIMock({ port: 0 }); + upstream.onPredicate(() => true, buildTextResponse("ok")); + const upstreamUrl = await upstream.start(); + + agui = new AGUIMock({ port: 0 }); + agui.enableRecording({ upstream: upstreamUrl, proxyOnly: false, fixturePath: tmpDir }); + await agui.start(); + + const resp = await post(agui.url, { + messages: [ + { + id: "u1", + role: "user", + content: [ + { + type: "document", + source: { type: "data", value: "AAA=", mimeType: "text/plain" }, + }, + ], + }, + ], + } as AGUIRunAgentInput); + expect(resp.status).toBe(200); + + const files = fs.readdirSync(tmpDir); + expect(files.length).toBe(1); + const parsed = JSON.parse(fs.readFileSync(path.join(tmpDir, files[0]), "utf-8")); + expect(parsed.fixtures[0].match.message).toBe(NO_USER_MESSAGE_SENTINEL); + }); +}); + +// --------------------------------------------------------------------------- +// NO_USER_MESSAGE_SENTINEL — wire-format compatibility guard +// --------------------------------------------------------------------------- + +describe("NO_USER_MESSAGE_SENTINEL", () => { + it("preserves the historical on-disk sentinel string", () => { + // Locking the literal value: existing recorded fixtures on disk use this + // exact string and must continue to round-trip without churn. + expect(NO_USER_MESSAGE_SENTINEL).toBe("__NO_USER_MESSAGE__"); + }); +}); diff --git a/src/agui-handler.ts b/src/agui-handler.ts index 5f82223d..791a01ad 100644 --- a/src/agui-handler.ts +++ b/src/agui-handler.ts @@ -47,18 +47,38 @@ import type { /** * Extract the content of the last message with role "user" from the input. + * Walks structured content arrays (e.g. `[{type:"text", text:"..."}, {type:"document", ...}]`) + * and joins their text parts. Returns `""` when no user message is present or has no text. */ export function extractLastUserMessage(input: AGUIRunAgentInput): string { if (!input.messages || input.messages.length === 0) return ""; for (let i = input.messages.length - 1; i >= 0; i--) { const msg = input.messages[i]; - if (msg.role === "user" && typeof msg.content === "string") { - return msg.content; - } + if (msg.role !== "user") continue; + const text = extractTextFromContent(msg.content); + if (text) return text; } return ""; } +function extractTextFromContent(content: AGUIMessage["content"]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const part of content) { + if ( + part && + typeof part === "object" && + part.type === "text" && + typeof part.text === "string" && + part.text + ) { + parts.push(part.text); + } + } + return parts.join(" ").trim(); +} + /** * Check whether an input matches a fixture's match criteria. * All specified criteria must pass (AND logic). diff --git a/src/agui-recorder.ts b/src/agui-recorder.ts index 51f521b6..4e934e26 100644 --- a/src/agui-recorder.ts +++ b/src/agui-recorder.ts @@ -7,6 +7,13 @@ import type { AGUIFixture, AGUIRecordConfig, AGUIEvent, AGUIRunAgentInput } from import { extractLastUserMessage } from "./agui-handler.js"; import type { Logger } from "./logger.js"; +/** + * Sentinel `match.message` value written to disk when the request had no + * extractable user text. Keeps the on-disk fixture serializable (predicate + * matchers aren't) but won't match any real user input on replay. + */ +export const NO_USER_MESSAGE_SENTINEL = "__NO_USER_MESSAGE__"; + /** * Proxy an unmatched AG-UI request to a real upstream agent, record the * SSE event stream as a fixture on disk and in memory, and relay the @@ -201,7 +208,7 @@ function teeUpstreamStream( }; if (!message) { logger.warn( - "Recorded AG-UI fixture has no user message — will use __NO_USER_MESSAGE__ sentinel on disk", + `Recorded AG-UI fixture has no user message — will use ${NO_USER_MESSAGE_SENTINEL} sentinel on disk`, ); } @@ -212,7 +219,9 @@ function teeUpstreamStream( // Write to disk — predicate functions are not serializable, // so replace with a sentinel string that won't match real user messages. const serializableFixture = { - match: fixture.match.predicate ? { message: "__NO_USER_MESSAGE__" } : fixture.match, + match: fixture.match.predicate + ? { message: NO_USER_MESSAGE_SENTINEL } + : fixture.match, events: fixture.events, ...(fixture.delayMs !== undefined ? { delayMs: fixture.delayMs } : {}), }; diff --git a/src/agui-stub.ts b/src/agui-stub.ts index d3b95544..158aec6f 100644 --- a/src/agui-stub.ts +++ b/src/agui-stub.ts @@ -42,6 +42,7 @@ export type { AGUIRunAgentInput, AGUIToolCall, AGUIMessage, + AGUIMessageContentPart, AGUIToolDefinition, AGUIInterrupt, AGUIResumeEntry, diff --git a/src/agui-types.ts b/src/agui-types.ts index aaf9c181..25e4e583 100644 --- a/src/agui-types.ts +++ b/src/agui-types.ts @@ -363,10 +363,14 @@ export interface AGUIToolCall { encryptedValue?: string; } +export type AGUIMessageContentPart = + | { type: "text"; text: string } + | { type: string; [key: string]: unknown }; + export interface AGUIMessage { id: string; role: AGUIMessageRole; - content?: string; + content?: string | AGUIMessageContentPart[]; name?: string; encryptedValue?: string; error?: string; diff --git a/src/index.ts b/src/index.ts index a5c133d8..8e887d3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,6 +204,7 @@ export type { AGUIMockOptions, AGUIRunAgentInput, AGUIMessage, + AGUIMessageContentPart, AGUIMessageRole, AGUIToolDefinition, AGUIToolCall,