diff --git a/packages/typescript/src/__tests__/comma-separated-action-params.test.ts b/packages/typescript/src/__tests__/comma-separated-action-params.test.ts new file mode 100644 index 0000000000000..9e6f2a3b067ba --- /dev/null +++ b/packages/typescript/src/__tests__/comma-separated-action-params.test.ts @@ -0,0 +1,155 @@ +/** + * Regression test: action params must be extracted from standalone XML blocks + * when the LLM outputs actions as a comma-separated list. + * + * The LLM may respond with: + * REPLY,START_CODING_TASK + * + * https://github.com/org/repo + * claude:Fix auth | codex:Write tests + * + * + * The XML parser puts "START_CODING_TASK" as a top-level key on parsedXml. + * extractStandaloneActionParams collects these into the legacy flat format + * that parseActionParams can consume downstream. + */ + +import { describe, expect, it } from "vitest"; +import { parseActionParams } from "../actions"; +import { + extractStandaloneActionParams, + RESERVED_XML_KEYS, +} from "../services/message"; + +// --------------------------------------------------------------------------- +// extractStandaloneActionParams +// --------------------------------------------------------------------------- + +describe("extractStandaloneActionParams", () => { + it("extracts params from a matching parsedXml key", () => { + const parsedXml = { + actions: "REPLY,START_CODING_TASK", + thought: "Spawning agents", + START_CODING_TASK: + "https://github.com/org/repoFix it", + }; + const result = extractStandaloneActionParams( + ["REPLY", "START_CODING_TASK"], + parsedXml, + ); + expect(result).toContain(""); + expect(result).toContain("https://github.com/org/repo"); + expect(result).toContain("Fix it"); + }); + + it("matches action names case-insensitively", () => { + const parsedXml = { + start_coding_task: "Fix", + }; + const result = extractStandaloneActionParams( + ["START_CODING_TASK"], + parsedXml, + ); + expect(result).toContain(""); + expect(result).toContain("Fix"); + }); + + it("skips reserved keys even if they match action names", () => { + const parsedXml = { + actions: "REPLY", + thought: "something", + text: "hello", + }; + const result = extractStandaloneActionParams( + ["actions", "thought", "text"], + parsedXml, + ); + expect(result).toBe(""); + }); + + it("skips keys that do not contain XML (plain text values)", () => { + const parsedXml = { + MY_ACTION: "just a plain string without XML", + }; + const result = extractStandaloneActionParams(["MY_ACTION"], parsedXml); + expect(result).toBe(""); + }); + + it("handles multiple actions with params", () => { + const parsedXml = { + START_CODING_TASK: "https://github.com/org/repo", + FINALIZE_WORKSPACE: "ws-123", + }; + const result = extractStandaloneActionParams( + ["START_CODING_TASK", "FINALIZE_WORKSPACE"], + parsedXml, + ); + expect(result).toContain(""); + expect(result).toContain(""); + }); + + it("returns empty string when no matches found", () => { + const result = extractStandaloneActionParams(["REPLY", "NONE"], {}); + expect(result).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// RESERVED_XML_KEYS +// --------------------------------------------------------------------------- + +describe("RESERVED_XML_KEYS", () => { + it("includes standard response schema fields", () => { + expect(RESERVED_XML_KEYS.has("actions")).toBe(true); + expect(RESERVED_XML_KEYS.has("thought")).toBe(true); + expect(RESERVED_XML_KEYS.has("text")).toBe(true); + expect(RESERVED_XML_KEYS.has("simple")).toBe(true); + expect(RESERVED_XML_KEYS.has("providers")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseActionParams integration (end-to-end with the assembled format) +// --------------------------------------------------------------------------- + +describe("parseActionParams with standalone action block format", () => { + it("extracts params from assembled ... format", () => { + // This is the format that extractStandaloneActionParams produces + const paramsXml = ` + https://github.com/org/repo + claude:Fix auth | codex:Write tests + Fix the login bug + `; + + const result = parseActionParams(paramsXml); + expect(result.has("START_CODING_TASK")).toBe(true); + + const params = result.get("START_CODING_TASK"); + expect(params).toBeTruthy(); + expect(params?.repo).toBe("https://github.com/org/repo"); + expect(params?.agents).toBe("claude:Fix auth | codex:Write tests"); + expect(params?.task).toBe("Fix the login bug"); + }); + + it("handles multiple action blocks", () => { + const paramsXml = ` + https://github.com/org/repo + Fix bugs + + + ws-123 + `; + + const result = parseActionParams(paramsXml); + expect(result.has("START_CODING_TASK")).toBe(true); + expect(result.has("FINALIZE_WORKSPACE")).toBe(true); + expect(result.get("START_CODING_TASK")?.task).toBe("Fix bugs"); + expect(result.get("FINALIZE_WORKSPACE")?.workspaceId).toBe("ws-123"); + }); + + it("returns empty map for empty input", () => { + expect(parseActionParams("").size).toBe(0); + expect(parseActionParams(undefined).size).toBe(0); + expect(parseActionParams(null).size).toBe(0); + }); +}); diff --git a/packages/typescript/src/services/message.ts b/packages/typescript/src/services/message.ts index 70ceeee13f32d..685c9d55a6403 100644 --- a/packages/typescript/src/services/message.ts +++ b/packages/typescript/src/services/message.ts @@ -49,6 +49,51 @@ import { hasFirstSentence, } from "../utils/text-splitting"; +/** + * Reserved XML response keys that are NOT action names. + * Used when scanning parsedXml for standalone action param blocks. + */ +export const RESERVED_XML_KEYS = new Set([ + "actions", + "thought", + "text", + "simple", + "providers", +]); + +/** + * Extract action params from standalone XML blocks in a parsedXml object. + * + * When the LLM outputs `REPLY,START_CODING_TASK` alongside + * `...`, the XML parser + * puts the action block as a top-level key on parsedXml. This function finds + * those keys and assembles them into the legacy flat params format that + * `parseActionParams` consumes. + * + * Returns the assembled params string, or empty string if none found. + */ +export function extractStandaloneActionParams( + actionNames: string[], + parsedXml: Record, +): string { + const fragments: string[] = []; + for (const actionName of actionNames) { + const upperName = actionName.toUpperCase(); + const matchingKey = Object.keys(parsedXml).find( + (k) => k.toUpperCase() === upperName, + ); + if ( + matchingKey && + !RESERVED_XML_KEYS.has(matchingKey.toLowerCase()) && + typeof parsedXml[matchingKey] === "string" && + (parsedXml[matchingKey] as string).includes("<") + ) { + fragments.push(`<${upperName}>${parsedXml[matchingKey]}`); + } + } + return fragments.join("\n"); +} + /** * Escape Handlebars syntax in a string to prevent template injection. * @@ -332,35 +377,33 @@ export class DefaultMessageService implements IMessageService { "nova"; let audioBuffer: Buffer | null = null; - const params: TextToSpeechParams & { - model?: string; - } = { - text: first, - voice: voiceId, - model: model, - }; - const result = runtime.getModel( - ModelType.TEXT_TO_SPEECH, - ) - ? await runtime.useModel( - ModelType.TEXT_TO_SPEECH, - params, - ) - : undefined; - - if ( - result instanceof ArrayBuffer || - Object.prototype.toString.call(result) === - "[object ArrayBuffer]" - ) { - audioBuffer = Buffer.from(result as ArrayBuffer); - } else if (Buffer.isBuffer(result)) { - audioBuffer = result; - } else if (result instanceof Uint8Array) { - audioBuffer = Buffer.from(result); - } - - + const params: TextToSpeechParams & { + model?: string; + } = { + text: first, + voice: voiceId, + model: model, + }; + const result = runtime.getModel( + ModelType.TEXT_TO_SPEECH, + ) + ? await runtime.useModel( + ModelType.TEXT_TO_SPEECH, + params, + ) + : undefined; + + if ( + result instanceof ArrayBuffer || + Object.prototype.toString.call(result) === + "[object ArrayBuffer]" + ) { + audioBuffer = Buffer.from(result as ArrayBuffer); + } else if (Buffer.isBuffer(result)) { + audioBuffer = result; + } else if (result instanceof Uint8Array) { + audioBuffer = Buffer.from(result); + } if (audioBuffer && callback) { const audioBase64 = audioBuffer.toString("base64"); @@ -1661,10 +1704,24 @@ export class DefaultMessageService implements IMessageService { } } // Legacy comma-separated format - return actionsXml + const commaSplitActions = actionsXml .split(",") .map((action) => String(action).trim()) .filter((action) => action.length > 0); + + // Extract params from standalone action blocks in parsedXml + // (e.g. ...). + if (!parsedXml.params || parsedXml.params === "") { + const assembled = extractStandaloneActionParams( + commaSplitActions, + parsedXml as Record, + ); + if (assembled) { + parsedXml.params = assembled; + } + } + + return commaSplitActions; } if (Array.isArray(parsedXml.actions)) { return parsedXml.actions as string[];