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]}${upperName}>`);
+ }
+ }
+ 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[];