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:
parent
bd96e3672b
commit
a3844a085f
18 changed files with 276 additions and 119 deletions
18
agent-teams-controller/src/internal/agentBlocks.js
Normal file
18
agent-teams-controller/src/internal/agentBlocks.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
32
test/shared/constants/agentBlocks.test.ts
Normal file
32
test/shared/constants/agentBlocks.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue