Files
multica/packages/core/agents/builder-protocol.test.ts
YYClaw 83422dc262 MUL-7464: fix(agents): recover completed builder drafts and align recovery feedback (#8514)
* fix(agents): recover completed builder drafts and align recovery feedback

* fix(agents): reject array builder draft payloads
2026-10-15 13:50:03 +08:00

360 lines
11 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
decodeBuilderInput,
encodeBuilderInput,
mergeBuilderDraft,
parseBuilderDraft,
pickBuilderRestore,
stripBuilderDraft,
} from "./builder-protocol";
import type { AgentDraft } from "./draft";
const draft = (): AgentDraft => ({
name: "Old name",
description: "Old description",
instructions: "Old instructions",
conversationStarters: [],
avatarUrl: null,
runtimeId: "runtime-1",
model: "model-1",
thinkingLevel: "",
serviceTier: "",
skillIds: new Set(["skill-1"]),
permissionScope: "private",
memberIds: new Set(),
teamIds: new Set(),
});
describe("agent builder protocol", () => {
it.each([
"[]",
'[{"name":"Reviewer"}]',
'[{"instructions":"First line\nSecond line"}]',
])("rejects array payloads: %j", (payload) => {
expect(
parseBuilderDraft(`<agent_draft>${payload}</agent_draft>`),
).toBeNull();
expect(
parseBuilderDraft(`<agent_draft>${payload}`, { completed: true }),
).toBeNull();
});
it("parses and hides the structured draft block", () => {
const content =
'Here is a first draft.\n<agent_draft>{"name":"Researcher","permission_scope":"workspace"}</agent_draft>';
expect(parseBuilderDraft(content)).toEqual({
name: "Researcher",
permission_scope: "workspace",
});
expect(stripBuilderDraft(content)).toBe("Here is a first draft.");
expect(parseBuilderDraft("<agent_draft>not json</agent_draft>")).toBeNull();
});
it("accepts up to three complete conversation starters from the builder", () => {
const merged = mergeBuilderDraft(
draft(),
{
conversation_starters: [
{ label: " Review a PR ", prompt: " Review the open PR. " },
{ label: "", prompt: "Ignored" },
],
},
new Set(),
new Set(),
new Set(["model-1"]),
);
expect(merged.conversationStarters).toEqual([
{ label: "Review a PR", prompt: "Review the open PR." },
]);
});
// Streaming delivers the reply token by token, so for the seconds it takes to
// emit the JSON there is an opening tag and no closing one. The closed-form
// pattern cannot match that, and the raw payload scrolled past the user on
// every turn.
it("hides a block that is still being streamed", () => {
expect(stripBuilderDraft('Here you go.\n<agent_draft>{"name":"Rel')).toBe(
"Here you go.",
);
});
it("repairs literal line breaks emitted inside the instructions string", () => {
const content = `<agent_draft>{"name":"Reviewer","instructions":"# Role
Review every change.
# Output
Return findings."}</agent_draft>`;
expect(parseBuilderDraft(content)).toEqual({
name: "Reviewer",
instructions:
"# Role\nReview every change.\n\n# Output\nReturn findings.",
});
});
it("round-trips only the user's natural-language request for chat display", () => {
const currentDraft = draft();
currentDraft.conversationStarters = [
{ label: "Plan a release", prompt: "Plan the next release." },
];
const content = encodeBuilderInput(
"Create a release manager",
currentDraft,
[],
[],
{ id: "runtime-1", name: "Codex", provider: "codex" },
[{ id: "gpt-5.5", label: "GPT-5.5", provider: "openai" }],
);
expect(decodeBuilderInput(content)).toBe("Create a release manager");
expect(JSON.parse(content.slice(content.indexOf("\n") + 1))).toMatchObject({
selected_runtime: {
id: "runtime-1",
name: "Codex",
provider: "codex",
},
available_runtime_models: [
{ id: "gpt-5.5", label: "GPT-5.5", provider: "openai" },
],
current_draft: {
conversation_starters: [
{ label: "Plan a release", prompt: "Plan the next release." },
],
},
});
expect(decodeBuilderInput("ordinary chat message")).toBe(
"ordinary chat message",
);
});
// The builder chat is a real chat_session, so cancelling a started-but-empty
// run defers the empty/non-empty judgment (#5219): the cancel response carries
// no restore_to_input and the prompt arrives later as a durable draft-restore
// row holding the ENCODED message. Handing that to the composer raw would show
// the user a wall of JSON instead of the sentence they typed.
it("decodes a durable draft restore before the builder composer adopts it", () => {
const encoded = encodeBuilderInput(
"Create a release manager",
draft(),
[],
[],
{ id: "runtime-1", name: "Codex", provider: "codex" },
[],
);
expect(pickBuilderRestore(null, { id: "msg-1", content: encoded })).toEqual(
{
id: "msg-1",
content: "Create a release manager",
},
);
expect(pickBuilderRestore(null, null)).toBeNull();
});
// The synchronous answer (task never started) is already decoded and already
// in hand; it must not be displaced by a durable row for the same cancel.
it("prefers the synchronous cancel answer over a durable restore", () => {
expect(
pickBuilderRestore(
{ id: "msg-1", content: "Create a release manager" },
{ id: "msg-1", content: "should not win" },
),
).toEqual({ id: "msg-1", content: "Create a release manager" });
});
it("merges safe fields and rejects unknown workspace references", () => {
const result = mergeBuilderDraft(
draft(),
{
name: "Release manager",
model: 123,
skill_ids: ["skill-2", "unknown-skill"],
permission_scope: "members",
member_ids: ["member-1", "unknown-member"],
},
new Set(["skill-1", "skill-2"]),
new Set(["member-1"]),
new Set(["model-1"]),
);
expect(result.name).toBe("Release manager");
expect(result.model).toBe("model-1");
expect([...result.skillIds]).toEqual(["skill-2"]);
expect(result.permissionScope).toBe("members");
expect([...result.memberIds]).toEqual(["member-1"]);
});
it("accepts catalog models and rejects invented or cross-runtime models", () => {
const validModelIds = new Set(["gpt-5.5", "gpt-5.3-codex"]);
expect(
mergeBuilderDraft(
draft(),
{ model: "gpt-5.5" },
new Set(),
new Set(),
validModelIds,
).model,
).toBe("gpt-5.5");
expect(
mergeBuilderDraft(
draft(),
{ model: "claude-3-5-sonnet" },
new Set(),
new Set(),
validModelIds,
).model,
).toBe("model-1");
expect(
mergeBuilderDraft(
draft(),
{ model: "invented-model" },
new Set(),
new Set(),
validModelIds,
).model,
).toBe("model-1");
expect(
mergeBuilderDraft(
draft(),
{ model: "" },
new Set(),
new Set(),
validModelIds,
).model,
).toBe("");
});
it("preserves a user-selected custom model when the catalog is unavailable", () => {
expect(
mergeBuilderDraft(
draft(),
{ model: "invented-model" },
new Set(),
new Set(),
null,
).model,
).toBe("model-1");
expect(
mergeBuilderDraft(
draft(),
{ model: "model-1" },
new Set(),
new Set(),
null,
).model,
).toBe("model-1");
expect(
mergeBuilderDraft(
draft(),
{ model: "invented-model" },
new Set(),
new Set(),
new Set(),
).model,
).toBe("model-1");
expect(
mergeBuilderDraft(draft(), { model: "" }, new Set(), new Set(), new Set())
.model,
).toBe("model-1");
});
it("keeps the overrides while the AI builder leaves the model alone", () => {
const current = {
...draft(),
thinkingLevel: "high",
serviceTier: "priority",
};
expect(
mergeBuilderDraft(
current,
{ name: "Renamed" },
new Set(),
new Set(),
new Set(["model-1"]),
),
).toMatchObject({
name: "Renamed",
model: "model-1",
thinkingLevel: "high",
serviceTier: "priority",
});
});
it("drops the overrides when the AI builder moves the model", () => {
const current = {
...draft(),
thinkingLevel: "high",
serviceTier: "priority",
};
expect(
mergeBuilderDraft(
current,
{ model: "gpt-5.4-mini" },
new Set(),
new Set(),
new Set(["model-1", "gpt-5.4-mini"]),
),
).toMatchObject({
model: "gpt-5.4-mini",
thinkingLevel: "",
serviceTier: "",
});
});
});
describe("completed builder reply recovery", () => {
const reply = 'Here is your draft.\n<agent_draft>{"name":"Poet","instructions":"# Role\\nWrite poetry."}';
it("recovers a complete JSON object only after the reply completes", () => {
expect(parseBuilderDraft(reply)).toBeNull();
expect(parseBuilderDraft(reply, { completed: false })).toBeNull();
expect(parseBuilderDraft(reply, { completed: true })).toEqual({
name: "Poet",
instructions: "# Role\nWrite poetry.",
});
});
it("recovers an unclosed completed draft with literal string control characters", () => {
const content = '<agent_draft>{"name":"诗仙李白","instructions":"第一行\n\n## 交流方式\r\n- 以第一人称对话。\t保持诗意。","permission_scope":"private"}';
expect(parseBuilderDraft(content)).toBeNull();
expect(parseBuilderDraft(content, { completed: false })).toBeNull();
expect(parseBuilderDraft(content, { completed: true })).toEqual({
name: "诗仙李白",
instructions: "第一行\n\n## 交流方式\r\n- 以第一人称对话。\t保持诗意。",
permission_scope: "private",
});
});
it.each([
'<agent_draft>{"name":"Poet"',
'<agent_draft>{"instructions":"line one\nline two"',
'<agent_draft>{"instructions":"line one\nline two"} trailing prose',
'<agent_draft>{"name":"Poet",}',
'<agent_draft>{"name":"Poet"} trailing prose',
'<agent_draft>{"name":"Poet"}<agent_draft>{"name":"Other"}',
'<agent_draft>[]',
'<agent_draft>',
'<agent_draft></agent_draft>',
'<agent_draft>{"name":"Poet"}</agent_draft',
'<agent_draft>null',
'<agent_draft>"Poet"',
'{"name":"Poet"}',
])("rejects malformed or ambiguous completed drafts: %s", (content) => {
expect(parseBuilderDraft(content, { completed: true })).toBeNull();
});
it("preserves closed-block parsing and literal-newline repair", () => {
expect(parseBuilderDraft('<agent_draft>{"instructions":"line one\nline two"}</agent_draft>'))
.toEqual({ instructions: "line one\nline two" });
expect(parseBuilderDraft(`${reply}</agent_draft>`, { completed: true }))
.toEqual(parseBuilderDraft(`${reply}</agent_draft>`));
});
});