From a3844a085ffeb3d6fa706f5dcacf41bf9e53509f Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 21:59:38 +0200 Subject: [PATCH] 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. --- .../src/internal/agentBlocks.js | 18 +++ agent-teams-controller/src/internal/review.js | 13 +- .../src/legacy/teamctl.cli.js | 35 ++++-- .../test/controller.test.js | 18 +++ .../services/team/TeamProvisioningService.ts | 5 +- .../components/sidebar/GlobalTaskList.tsx | 4 +- .../components/team/TeamDetailView.tsx | 24 ++-- .../team/activity/ActiveTasksBlock.tsx | 2 +- .../team/activity/PendingRepliesBlock.tsx | 21 ++-- .../team/dialogs/AddMemberDialog.tsx | 118 ++++++++++-------- .../team/dialogs/TaskDetailDialog.tsx | 2 +- .../team/members/MemberExecutionLog.tsx | 13 +- .../components/ui/MentionableTextarea.tsx | 2 +- src/renderer/index.css | 16 +++ src/shared/constants/agentBlocks.ts | 64 ++++++++-- .../TeamProvisioningServicePrompts.test.ts | 6 + test/main/services/team/teamctl.test.ts | 2 +- test/shared/constants/agentBlocks.test.ts | 32 +++++ 18 files changed, 276 insertions(+), 119 deletions(-) create mode 100644 agent-teams-controller/src/internal/agentBlocks.js create mode 100644 test/shared/constants/agentBlocks.test.ts diff --git a/agent-teams-controller/src/internal/agentBlocks.js b/agent-teams-controller/src/internal/agentBlocks.js new file mode 100644 index 00000000..9913af91 --- /dev/null +++ b/agent-teams-controller/src/internal/agentBlocks.js @@ -0,0 +1,18 @@ +const AGENT_BLOCK_TAG = 'info_for_agent'; +const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`; +const AGENT_BLOCK_CLOSE = ``; + +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, +}; diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index 6ce6fcb8..3599d7b2 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -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` + - '\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` + - '', + 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', }); diff --git a/agent-teams-controller/src/legacy/teamctl.cli.js b/agent-teams-controller/src/legacy/teamctl.cli.js index 95efa6f8..29dc925a 100644 --- a/agent-teams-controller/src/legacy/teamctl.cli.js +++ b/agent-teams-controller/src/legacy/teamctl.cli.js @@ -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 = ''; 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, diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 18c1b3c8..c8c9bfba 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -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(''); + expect(inbox[0].text).toContain('review_approve'); + expect(inbox[0].text).not.toContain(''); + }); + it('persists full inbox metadata through controller messages.sendMessage', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 36c6fbfc..e74ebc23 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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.) diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 38404120..08eddf6b 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -495,7 +495,7 @@ export const GlobalTaskList = ({
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' )} > diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index a65eebdb..80069b40 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1551,17 +1551,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }); }} /> - - +
+ + +
- + + + + + + + {onMemberClick ? ( - - + + + + + ); diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index a32e0cb6..5b7446f4 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -328,7 +328,7 @@ export const TaskDetailDialog = ({ return ( !v && onClose()}> - +
diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index 7073553f..f83ec00b 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -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 }; } diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index ececdb21..c3739579 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -620,7 +620,7 @@ export const MentionableTextarea = React.forwardRef [ '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", ], [] diff --git a/src/renderer/index.css b/src/renderer/index.css index 0510fed6..c9bc3050 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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); diff --git a/src/shared/constants/agentBlocks.ts b/src/shared/constants/agentBlocks.ts index 6cf974a1..3d596c39 100644 --- a/src/shared/constants/agentBlocks.ts +++ b/src/shared/constants/agentBlocks.ts @@ -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: + * * ... agent-only instructions ... - * ``` + * + * + * Backward compatibility: + * - legacy fenced blocks: ```info_for_agent ... ``` + * - legacy xml-like blocks: ... */ 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 = ``; /** - * 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?\\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?\\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 = ''; + const legacyXmlClose = ''; + 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); } /** diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 0c8e358a..bdb5956b 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -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'); diff --git a/test/main/services/team/teamctl.test.ts b/test/main/services/team/teamctl.test.ts index 7b3b0ee5..09b59e79 100644 --- a/test/main/services/team/teamctl.test.ts +++ b/test/main/services/team/teamctl.test.ts @@ -2540,7 +2540,7 @@ describe('teamctl.js', () => { ]); const inbox = readInbox(claudeDir, 'bob') as Record[]; const text = String(inbox[0].text); - // Must contain agent block markers (```info_for_agent ... ```) + // Must contain agent block markers ( ... ) expect(text).toContain('info_for_agent'); expect(text).toContain('task start'); expect(text).toContain('task complete'); diff --git a/test/shared/constants/agentBlocks.test.ts b/test/shared/constants/agentBlocks.test.ts new file mode 100644 index 00000000..20af24d6 --- /dev/null +++ b/test/shared/constants/agentBlocks.test.ts @@ -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\nhidden xml\n\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('\ninside\n')).toBe('inside'); + }); +});