Skip to content

Commit 268170e

Browse files
HaruHunab1320claude
andcommitted
fix: extract action params from standalone XML blocks in comma-separated format
When the LLM outputs actions as a comma-separated list (`<actions>REPLY,START_CODING_TASK</actions>`), the parser extracted action names but dropped their parameters. The LLM also outputs standalone blocks like `<START_CODING_TASK><repo>...</repo></START_CODING_TASK>` as sibling XML elements — the XML parser puts these as top-level keys on parsedXml, but the message service never collected them into parsedXml.params for downstream consumption by parseActionParams. This caused action handlers to receive empty parameters, breaking features like coding agent swarm spawning where task/agents/repo params are required for coordinator registration. The fix scans parsedXml for top-level keys matching comma-split action names and assembles them into the legacy flat params format that parseActionParams already handles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4eab5df commit 268170e

File tree

2 files changed

+158
-30
lines changed

2 files changed

+158
-30
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Regression test: action params must be extracted from standalone XML blocks
3+
* when the LLM outputs actions as a comma-separated list.
4+
*
5+
* The LLM may respond with:
6+
* <actions>REPLY,START_CODING_TASK</actions>
7+
* <START_CODING_TASK>
8+
* <repo>https://github.com/org/repo</repo>
9+
* <agents>claude:Fix auth | codex:Write tests</agents>
10+
* </START_CODING_TASK>
11+
*
12+
* The XML parser puts "START_CODING_TASK" as a top-level key on parsedXml.
13+
* The message service must collect these into parsedXml.params so that
14+
* parseActionParams can extract them downstream.
15+
*/
16+
17+
import { readFileSync } from "node:fs";
18+
import path from "node:path";
19+
import { describe, expect, it } from "vitest";
20+
import { parseActionParams } from "../actions";
21+
22+
const messageServiceSource = readFileSync(
23+
path.resolve(import.meta.dirname, "..", "services", "message.ts"),
24+
"utf-8",
25+
);
26+
27+
describe("comma-separated action params extraction (message service)", () => {
28+
it("scans parsedXml for standalone action blocks in the comma-split path", () => {
29+
// The fix must look for top-level parsedXml keys matching action names
30+
expect(messageServiceSource).toContain("standaloneParamsFragments");
31+
expect(messageServiceSource).toContain("commaSplitActions");
32+
});
33+
34+
it("skips reserved keys (actions, thought, text, simple)", () => {
35+
expect(messageServiceSource).toMatch(/matchingKey\s*!==\s*"actions"/);
36+
expect(messageServiceSource).toMatch(/matchingKey\s*!==\s*"thought"/);
37+
expect(messageServiceSource).toMatch(/matchingKey\s*!==\s*"text"/);
38+
});
39+
40+
it("only populates params when not already set", () => {
41+
// Guard: don't overwrite explicit params from structured <action> blocks
42+
expect(messageServiceSource).toMatch(
43+
/!parsedXml\.params\s*\|\|\s*parsedXml\.params\s*===\s*""/,
44+
);
45+
});
46+
47+
it("assembles params in legacy flat format for parseActionParams", () => {
48+
// The assembled string wraps action content: <UPPER_NAME>content</UPPER_NAME>
49+
expect(messageServiceSource).toContain("standaloneParamsFragments.push");
50+
expect(messageServiceSource).toContain(
51+
"parsedXml.params = standaloneParamsFragments",
52+
);
53+
});
54+
});
55+
56+
describe("parseActionParams handles standalone action block format", () => {
57+
it("extracts params from <ACTION_NAME><key>value</key></ACTION_NAME> format", () => {
58+
const paramsXml = `<START_CODING_TASK>
59+
<repo>https://github.com/org/repo</repo>
60+
<agents>claude:Fix auth | codex:Write tests</agents>
61+
<task>Fix the login bug</task>
62+
</START_CODING_TASK>`;
63+
64+
const result = parseActionParams(paramsXml);
65+
expect(result.has("START_CODING_TASK")).toBe(true);
66+
67+
const params = result.get("START_CODING_TASK");
68+
expect(params).toBeTruthy();
69+
expect(params?.repo).toBe("https://github.com/org/repo");
70+
expect(params?.agents).toBe("claude:Fix auth | codex:Write tests");
71+
expect(params?.task).toBe("Fix the login bug");
72+
});
73+
74+
it("extracts params from multiple action blocks", () => {
75+
const paramsXml = `<START_CODING_TASK>
76+
<repo>https://github.com/org/repo</repo>
77+
<task>Fix bugs</task>
78+
</START_CODING_TASK>
79+
<FINALIZE_WORKSPACE>
80+
<workspaceId>ws-123</workspaceId>
81+
</FINALIZE_WORKSPACE>`;
82+
83+
const result = parseActionParams(paramsXml);
84+
expect(result.has("START_CODING_TASK")).toBe(true);
85+
expect(result.has("FINALIZE_WORKSPACE")).toBe(true);
86+
expect(result.get("START_CODING_TASK")?.task).toBe("Fix bugs");
87+
expect(result.get("FINALIZE_WORKSPACE")?.workspaceId).toBe("ws-123");
88+
});
89+
90+
it("returns empty map for empty input", () => {
91+
expect(parseActionParams("").size).toBe(0);
92+
expect(parseActionParams(undefined).size).toBe(0);
93+
expect(parseActionParams(null).size).toBe(0);
94+
});
95+
});

packages/typescript/src/services/message.ts

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -332,35 +332,33 @@ export class DefaultMessageService implements IMessageService {
332332
"nova";
333333

334334
let audioBuffer: Buffer | null = null;
335-
const params: TextToSpeechParams & {
336-
model?: string;
337-
} = {
338-
text: first,
339-
voice: voiceId,
340-
model: model,
341-
};
342-
const result = runtime.getModel(
343-
ModelType.TEXT_TO_SPEECH,
344-
)
345-
? await runtime.useModel(
346-
ModelType.TEXT_TO_SPEECH,
347-
params,
348-
)
349-
: undefined;
350-
351-
if (
352-
result instanceof ArrayBuffer ||
353-
Object.prototype.toString.call(result) ===
354-
"[object ArrayBuffer]"
355-
) {
356-
audioBuffer = Buffer.from(result as ArrayBuffer);
357-
} else if (Buffer.isBuffer(result)) {
358-
audioBuffer = result;
359-
} else if (result instanceof Uint8Array) {
360-
audioBuffer = Buffer.from(result);
361-
}
362-
363-
335+
const params: TextToSpeechParams & {
336+
model?: string;
337+
} = {
338+
text: first,
339+
voice: voiceId,
340+
model: model,
341+
};
342+
const result = runtime.getModel(
343+
ModelType.TEXT_TO_SPEECH,
344+
)
345+
? await runtime.useModel(
346+
ModelType.TEXT_TO_SPEECH,
347+
params,
348+
)
349+
: undefined;
350+
351+
if (
352+
result instanceof ArrayBuffer ||
353+
Object.prototype.toString.call(result) ===
354+
"[object ArrayBuffer]"
355+
) {
356+
audioBuffer = Buffer.from(result as ArrayBuffer);
357+
} else if (Buffer.isBuffer(result)) {
358+
audioBuffer = result;
359+
} else if (result instanceof Uint8Array) {
360+
audioBuffer = Buffer.from(result);
361+
}
364362

365363
if (audioBuffer && callback) {
366364
const audioBase64 = audioBuffer.toString("base64");
@@ -1661,10 +1659,45 @@ export class DefaultMessageService implements IMessageService {
16611659
}
16621660
}
16631661
// Legacy comma-separated format
1664-
return actionsXml
1662+
const commaSplitActions = actionsXml
16651663
.split(",")
16661664
.map((action) => String(action).trim())
16671665
.filter((action) => action.length > 0);
1666+
1667+
// Extract params from standalone action blocks in parsedXml.
1668+
// When the LLM outputs <actions>REPLY,START_CODING_TASK</actions>
1669+
// it may also output <START_CODING_TASK><repo>...</repo></START_CODING_TASK>
1670+
// as a sibling element. The XML parser puts that into parsedXml
1671+
// as a top-level key. We need to collect those into parsedXml.params
1672+
// so that parseActionParams can consume them downstream.
1673+
if (!parsedXml.params || parsedXml.params === "") {
1674+
const standaloneParamsFragments: string[] = [];
1675+
for (const actionName of commaSplitActions) {
1676+
const upperName = actionName.toUpperCase();
1677+
// Check parsedXml for a key matching the action name (case-insensitive)
1678+
const matchingKey = Object.keys(parsedXml).find(
1679+
(k) => k.toUpperCase() === upperName,
1680+
);
1681+
if (
1682+
matchingKey &&
1683+
matchingKey !== "actions" &&
1684+
matchingKey !== "thought" &&
1685+
matchingKey !== "text" &&
1686+
matchingKey !== "simple" &&
1687+
typeof parsedXml[matchingKey] === "string" &&
1688+
(parsedXml[matchingKey] as string).includes("<")
1689+
) {
1690+
standaloneParamsFragments.push(
1691+
`<${upperName}>${parsedXml[matchingKey]}</${upperName}>`,
1692+
);
1693+
}
1694+
}
1695+
if (standaloneParamsFragments.length > 0) {
1696+
parsedXml.params = standaloneParamsFragments.join("\n");
1697+
}
1698+
}
1699+
1700+
return commaSplitActions;
16681701
}
16691702
if (Array.isArray(parsedXml.actions)) {
16701703
return parsedXml.actions as string[];

0 commit comments

Comments
 (0)