mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
* fix(agents): recover completed builder drafts and align recovery feedback * fix(agents): reject array builder draft payloads
360 lines
11 KiB
TypeScript
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>`));
|
|
});
|
|
});
|