refactor: standardize agent block handling and improve messaging format

- Introduced a new `wrapAgentBlock` function to standardize the formatting of agent-only messages across the application.
- Updated the `requestReview` method to utilize the new agent block format, enhancing consistency in review request messages.
- Refactored legacy agent block handling to support both new XML-like and legacy fenced formats, ensuring backward compatibility.
- Enhanced tests to validate the new agent block formatting and ensure proper extraction of agent-only content from messages.
This commit is contained in:
iliya 2026-03-07 21:59:38 +02:00
parent bd96e3672b
commit a3844a085f
18 changed files with 276 additions and 119 deletions

View file

@ -0,0 +1,18 @@
const AGENT_BLOCK_TAG = 'info_for_agent';
const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
const AGENT_BLOCK_CLOSE = `</${AGENT_BLOCK_TAG}>`;
function wrapAgentBlock(text) {
const trimmed = typeof text === 'string' ? text.trim() : '';
if (!trimmed) {
return '';
}
return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
}
module.exports = {
AGENT_BLOCK_TAG,
AGENT_BLOCK_OPEN,
AGENT_BLOCK_CLOSE,
wrapAgentBlock,
};

View file

@ -1,6 +1,7 @@
const kanban = require('./kanban.js');
const messages = require('./messages.js');
const tasks = require('./tasks.js');
const { wrapAgentBlock } = require('./agentBlocks.js');
function getReviewer(context, flags) {
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
@ -33,12 +34,12 @@ function requestReview(context, taskId, flags = {}) {
from,
text:
`Please review task #${task.displayId || task.id}.\n\n` +
'<agent-block>\n' +
`When approved, use MCP tool review_approve:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
`If changes are needed, use MCP tool review_request_changes:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }\n` +
'</agent-block>',
wrapAgentBlock(
`When approved, use MCP tool review_approve:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
`If changes are needed, use MCP tool review_request_changes:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }`
),
summary: `Review request for #${task.displayId || task.id}`,
source: 'system_notification',
});

View file

@ -8,11 +8,20 @@ const path = require('path');
const crypto = require('crypto');
const TOOL_VERSION = '1.0.0';
const AGENT_BLOCK_TAG = 'info_for_agent';
const AGENT_BLOCK_OPEN = '<' + AGENT_BLOCK_TAG + '>';
const AGENT_BLOCK_CLOSE = '</' + AGENT_BLOCK_TAG + '>';
function nowIso() {
return new Date().toISOString();
}
function wrapAgentBlock(text) {
const trimmed = typeof text === 'string' ? text.trim() : '';
if (!trimmed) return '';
return AGENT_BLOCK_OPEN + '\n' + trimmed + '\n' + AGENT_BLOCK_CLOSE;
}
function makeId() {
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random());
}
@ -1261,11 +1270,14 @@ async function main() {
parts.push('\nInstructions:\n' + prompt);
}
parts.push(
'\n' + "```info_for_agent",
'Update task status using:',
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
"```"
'\n' +
wrapAgentBlock(
[
'Update task status using:',
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
].join('\n')
)
);
sendInboxMessage(paths, teamName, {
to: task.owner,
@ -1377,11 +1389,14 @@ async function main() {
parts.push('\nDescription:\n' + String(task.description).slice(0, 500));
}
parts.push(
'\n' + "```info_for_agent",
'Update task status using:',
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
"```"
'\n' +
wrapAgentBlock(
[
'Update task status using:',
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
].join('\n')
)
);
sendInboxMessage(paths, teamName, {
to: effectiveOwner,

View file

@ -267,6 +267,24 @@ describe('agent-teams-controller API', () => {
]);
});
it('wraps review instructions in the canonical agent block format used by the UI', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead' });
const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
expect(inbox).toHaveLength(1);
expect(inbox[0].text).toContain('<info_for_agent>');
expect(inbox[0].text).toContain('review_approve');
expect(inbox[0].text).not.toContain('<agent-block>');
});
it('persists full inbox metadata through controller messages.sendMessage', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -658,15 +658,16 @@ function buildAgentBlockUsagePolicy(): string {
return `Agent-only formatting policy (applies to ALL messages you write):
- Humans can see teammate inbox messages and coordination text in the UI.
- Keep normal reasoning, decisions, and user-facing communication OUTSIDE agent-only blocks.
- Use agent-only blocks specifically for hidden internal instructions sent between agents/teammates that the human user must NOT see in the UI.
- Any internal operational instructions about tooling/scripts MUST be hidden inside an agent-only block, including:
- how to use internal MCP tools, exact tool names, and argument shapes
- review command phrases like "review_approve" / "review_request_changes"
- internal file paths under ~/.claude/ (teams, tasks, kanban state, etc.)
- meta coordination lines like "All teammates are online and have received their assignments via --notify."
- Use an agent-only fenced block (AGENT_BLOCK_OPEN / AGENT_BLOCK_CLOSE):
- Use an agent-only tag block (AGENT_BLOCK_OPEN / AGENT_BLOCK_CLOSE):
- AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}
- AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}
- IMPORTANT: the fence lines must start at the beginning of the line (no indentation).
- IMPORTANT: put the opening tag and closing tag on their own lines with no indentation.
- Example (copy/paste exactly, no indentation):
${AGENT_BLOCK_OPEN}
(internal instructions: commands, script usage, paths, etc.)

View file

@ -495,7 +495,7 @@ export const GlobalTaskList = ({
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
<div
className="border-border-emphasis/40 inline-flex rounded-md border bg-[var(--color-surface)] p-0.5 text-[11px]"
className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5 text-[11px]"
role="group"
aria-label="Group by"
>
@ -509,7 +509,7 @@ export const GlobalTaskList = ({
className={cn(
'rounded px-2 py-0.5 transition-colors',
groupingMode === mode
? 'ring-border-emphasis/60 bg-surface-raised text-text shadow-sm ring-1'
? 'bg-surface-raised text-text-secondary shadow-sm ring-1 ring-[var(--color-border)]'
: 'text-text-muted hover:text-text-secondary'
)}
>

View file

@ -1551,17 +1551,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
});
}}
/>
<PendingRepliesBlock
members={data.members}
pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={setSelectedMember}
/>
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
/>
<div className="mb-[35px]">
<PendingRepliesBlock
members={data.members}
pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={setSelectedMember}
/>
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
/>
</div>
<ActivityTimeline
messages={filteredMessages}
teamName={teamName}

View file

@ -42,7 +42,7 @@ export const ActiveTasksBlock = ({
return (
<article
key={`${member.name}-${taskId}`}
className="overflow-hidden rounded-md"
className="activity-card-enter-animate overflow-hidden rounded-md"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,

View file

@ -2,9 +2,8 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { formatDistanceToNowStrict } from 'date-fns';
import { Loader2 } from 'lucide-react';
import type { ResolvedTeamMember } from '@shared/types';
@ -47,7 +46,7 @@ export const PendingRepliesBlock = ({
return (
<article
key={`pending-reply:${member.name}:${sentAtMs}`}
className="overflow-hidden rounded-md"
className="activity-card-enter-animate overflow-hidden rounded-md"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
@ -55,10 +54,18 @@ export const PendingRepliesBlock = ({
}}
>
<div className="flex items-center gap-2 px-3 py-2">
<Loader2
className="size-3.5 shrink-0 animate-spin"
style={{ color: colors.border }}
/>
<span className="relative inline-flex shrink-0">
<img
src={agentAvatarUrl(member.name, 24)}
alt=""
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span className="absolute -bottom-0.5 -right-0.5 flex h-2.5 w-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-70" />
<span className="relative inline-flex size-full rounded-full bg-emerald-500" />
</span>
</span>
{onMemberClick ? (
<button
type="button"

View file

@ -102,6 +102,10 @@ export const AddMemberDialog = ({
setError(null);
const wf = workflowDraft.value.trim() || undefined;
onAdd(name.trim().toLowerCase(), effectiveRole, wf);
// Reset form fields after successful submission
setName('');
setRoleSelect(NO_ROLE);
setCustomRole('');
workflowDraft.clearDraft();
};
@ -132,63 +136,67 @@ export const AddMemberDialog = ({
<DialogDescription>Add a new member to {teamName}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. alice, dev-1"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
autoFocus
/>
{error && <p className="text-xs text-red-400">{error}</p>}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. alice, dev-1"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
autoFocus
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
<div className="space-y-2">
<Label className="label-optional">Role (optional)</Label>
<RoleSelect
value={roleSelect}
onValueChange={setRoleSelect}
customRole={customRole}
onCustomRoleChange={setCustomRole}
/>
</div>
<div className="space-y-2">
<Label className="label-optional">Workflow (optional)</Label>
<MentionableTextarea
className="text-xs"
minRows={3}
maxRows={8}
value={workflowDraft.value}
onValueChange={handleWorkflowChange}
suggestions={mentionSuggestions}
projectPath={projectPath ?? undefined}
placeholder="How this agent should behave, what tasks it handles..."
footerRight={
workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="label-optional">Role (optional)</Label>
<RoleSelect
value={roleSelect}
onValueChange={setRoleSelect}
customRole={customRole}
onCustomRoleChange={setCustomRole}
/>
</div>
<div className="space-y-2">
<Label className="label-optional">Workflow (optional)</Label>
<MentionableTextarea
className="text-xs"
minRows={3}
maxRows={8}
value={workflowDraft.value}
onValueChange={handleWorkflowChange}
suggestions={mentionSuggestions}
projectPath={projectPath ?? undefined}
placeholder="How this agent should behave, what tasks it handles..."
footerRight={
workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={adding}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={adding || !name.trim()}>
{adding ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : null}
Add
</Button>
</DialogFooter>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={adding}>
Cancel
</Button>
<Button type="submit" disabled={adding || !name.trim()}>
{adding ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : null}
Add
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);

View file

@ -328,7 +328,7 @@ export const TaskDetailDialog = ({
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="min-w-0 sm:max-w-4xl">
<DialogContent className="sm:min-w-[500px] sm:max-w-4xl">
<DialogHeader>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">

View file

@ -7,7 +7,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { createAgentBlockRegex, stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractAgentBlockContents, stripAgentBlocks } from '@shared/constants/agentBlocks';
import { format } from 'date-fns';
import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
@ -92,16 +92,7 @@ export const MemberExecutionLog = ({
/** Extract agent-only instruction blocks and human-visible text from a message. */
function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[] } {
const agentInfo: string[] = [];
const regex = createAgentBlockRegex();
let m: RegExpExecArray | null;
while ((m = regex.exec(raw)) !== null) {
const content = m[0]
.replace(/^```info_for_agent\n?/, '')
.replace(/\n?```$/, '')
.trim();
if (content) agentInfo.push(content);
}
const agentInfo = extractAgentBlockContents(raw);
const humanText = stripAgentBlocks(raw);
return { humanText, agentInfo };
}

View file

@ -620,7 +620,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const rotatingTips = React.useMemo(
() => [
'Tip: Use @ to mention team members or search files',
'Tip: Mention "delegate a task to a teammate" to add it to the kanban',
'Tip: Mention "create a task" to add it to the kanban',
"Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
],
[]

View file

@ -655,6 +655,22 @@ body {
animation: thought-expand 350ms ease-out both;
}
@keyframes activity-card-enter {
from {
max-height: 0;
opacity: 0;
overflow: hidden;
}
to {
max-height: 80px;
opacity: 1;
}
}
.activity-card-enter-animate {
animation: activity-card-enter 300ms ease-out both;
}
@keyframes att-scale-in {
from {
transform: scale(0);

View file

@ -1,22 +1,28 @@
/**
* Fenced code block marker for agent-only content.
* XML-like marker for agent-only content.
* Content wrapped in these markers is intended for the agent (Claude Code)
* and should be hidden from the human user in the UI.
*
* Format:
* ```info_for_agent
* Canonical format:
* <info_for_agent>
* ... agent-only instructions ...
* ```
* </info_for_agent>
*
* Backward compatibility:
* - legacy fenced blocks: ```info_for_agent ... ```
* - legacy xml-like blocks: <agent-block> ... </agent-block>
*/
export const AGENT_BLOCK_TAG = 'info_for_agent';
export const AGENT_BLOCK_OPEN = '```' + AGENT_BLOCK_TAG;
export const AGENT_BLOCK_CLOSE = '```';
export const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
export const AGENT_BLOCK_CLOSE = `</${AGENT_BLOCK_TAG}>`;
/**
* Regex pattern string for matching ``` info_for_agent ... ``` blocks (including fences).
* Supports optional leading/trailing whitespace and newlines around the block.
* Regex pattern string for matching current and legacy agent-only blocks.
*/
const AGENT_BLOCK_PATTERN = '\\n?```' + AGENT_BLOCK_TAG + '\\n[\\s\\S]*?\\n```\\n?';
const CURRENT_AGENT_BLOCK_PATTERN = '\\n?<info_for_agent>\\n?[\\s\\S]*?\\n?<\\/info_for_agent>\\n?';
const LEGACY_FENCED_AGENT_BLOCK_PATTERN = '\\n?```' + AGENT_BLOCK_TAG + '\\n[\\s\\S]*?\\n```\\n?';
const LEGACY_XML_AGENT_BLOCK_PATTERN = '\\n?<agent-block>\\n?[\\s\\S]*?\\n?<\\/agent-block>\\n?';
const AGENT_BLOCK_PATTERN = `(?:${CURRENT_AGENT_BLOCK_PATTERN}|${LEGACY_FENCED_AGENT_BLOCK_PATTERN}|${LEGACY_XML_AGENT_BLOCK_PATTERN})`;
/**
* Creates a new RegExp for matching agent blocks.
@ -27,10 +33,46 @@ export function createAgentBlockRegex(): RegExp {
}
/**
* Removes ```info_for_agent ... ``` blocks from text for UI display.
* Removes the current and legacy agent-only blocks from text for UI display.
*/
export function stripAgentBlocks(text: string): string {
return text.replace(createAgentBlockRegex(), '').trim();
return text
.replace(createAgentBlockRegex(), '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/**
* Removes only the wrapper markers from a single agent block.
*/
export function unwrapAgentBlock(block: string): string {
const trimmed = block.trim();
if (trimmed.startsWith(AGENT_BLOCK_OPEN) && trimmed.endsWith(AGENT_BLOCK_CLOSE)) {
return trimmed.slice(AGENT_BLOCK_OPEN.length, -AGENT_BLOCK_CLOSE.length).trim();
}
const legacyFencedOpen = '```' + AGENT_BLOCK_TAG;
if (trimmed.startsWith(legacyFencedOpen) && trimmed.endsWith('```')) {
return trimmed.slice(legacyFencedOpen.length, -'```'.length).trim();
}
const legacyXmlOpen = '<agent-block>';
const legacyXmlClose = '</agent-block>';
if (trimmed.startsWith(legacyXmlOpen) && trimmed.endsWith(legacyXmlClose)) {
return trimmed.slice(legacyXmlOpen.length, -legacyXmlClose.length).trim();
}
return trimmed;
}
/**
* Extracts agent-only block contents without the wrapper markers.
*/
export function extractAgentBlockContents(text: string): string[] {
return Array.from(text.matchAll(createAgentBlockRegex()))
.map((match) => unwrapAgentBlock(match[0]))
.filter((content) => content.length > 0);
}
/**

View file

@ -5,6 +5,8 @@ import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
let tempClaudeRoot = '';
let tempTeamsBase = '';
let tempTasksBase = '';
@ -112,6 +114,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('Default to working ONE task at a time');
expect(prompt).toContain('task_start');
expect(prompt).toContain('task_complete');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
expect(prompt).not.toContain('teamctl.js');
expect(prompt).not.toContain('.claude/tools');
@ -172,6 +176,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('Execute tasks sequentially and keep the board + user updated');
expect(prompt).toContain('Do NOT start the next task until the current task is completed');
expect(prompt).toContain('task_start');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
expect(prompt).not.toContain('teamctl.js');
expect(prompt).not.toContain('.claude/tools');

View file

@ -2540,7 +2540,7 @@ describe('teamctl.js', () => {
]);
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
const text = String(inbox[0].text);
// Must contain agent block markers (```info_for_agent ... ```)
// Must contain agent block markers (<info_for_agent> ... </info_for_agent>)
expect(text).toContain('info_for_agent');
expect(text).toContain('task start');
expect(text).toContain('task complete');

View file

@ -0,0 +1,32 @@
import {
AGENT_BLOCK_CLOSE,
AGENT_BLOCK_OPEN,
extractAgentBlockContents,
stripAgentBlocks,
unwrapAgentBlock,
} from '@shared/constants/agentBlocks';
describe('agentBlocks', () => {
it('strips the canonical info_for_agent tags from display text', () => {
const text = `Visible line\n${AGENT_BLOCK_OPEN}\ninternal instruction\n${AGENT_BLOCK_CLOSE}\nAfter`;
expect(stripAgentBlocks(text)).toBe('Visible line\nAfter');
expect(extractAgentBlockContents(text)).toEqual(['internal instruction']);
});
it('keeps backward compatibility for legacy agent block formats', () => {
const legacyFenced = 'Hello\n```info_for_agent\nhidden fenced\n```\nWorld';
const legacyXml = 'Hello\n<agent-block>\nhidden xml\n</agent-block>\nWorld';
expect(stripAgentBlocks(legacyFenced)).toBe('Hello\nWorld');
expect(stripAgentBlocks(legacyXml)).toBe('Hello\nWorld');
expect(extractAgentBlockContents(legacyFenced)).toEqual(['hidden fenced']);
expect(extractAgentBlockContents(legacyXml)).toEqual(['hidden xml']);
});
it('unwraps canonical and legacy wrappers consistently', () => {
expect(unwrapAgentBlock(`${AGENT_BLOCK_OPEN}\ninside\n${AGENT_BLOCK_CLOSE}`)).toBe('inside');
expect(unwrapAgentBlock('```info_for_agent\ninside\n```')).toBe('inside');
expect(unwrapAgentBlock('<agent-block>\ninside\n</agent-block>')).toBe('inside');
});
});