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 kanban = require('./kanban.js');
|
||||||
const messages = require('./messages.js');
|
const messages = require('./messages.js');
|
||||||
const tasks = require('./tasks.js');
|
const tasks = require('./tasks.js');
|
||||||
|
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||||
|
|
||||||
function getReviewer(context, flags) {
|
function getReviewer(context, flags) {
|
||||||
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
|
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
|
||||||
|
|
@ -33,12 +34,12 @@ function requestReview(context, taskId, flags = {}) {
|
||||||
from,
|
from,
|
||||||
text:
|
text:
|
||||||
`Please review task #${task.displayId || task.id}.\n\n` +
|
`Please review task #${task.displayId || task.id}.\n\n` +
|
||||||
'<agent-block>\n' +
|
wrapAgentBlock(
|
||||||
`When approved, use MCP tool review_approve:\n` +
|
`When approved, use MCP tool review_approve:\n` +
|
||||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
|
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
|
||||||
`If changes are needed, use MCP tool review_request_changes:\n` +
|
`If changes are needed, use MCP tool review_request_changes:\n` +
|
||||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }\n` +
|
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }`
|
||||||
'</agent-block>',
|
),
|
||||||
summary: `Review request for #${task.displayId || task.id}`,
|
summary: `Review request for #${task.displayId || task.id}`,
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,20 @@ const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const TOOL_VERSION = '1.0.0';
|
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() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
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() {
|
function makeId() {
|
||||||
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random());
|
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('\nInstructions:\n' + prompt);
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
'\n' + "```info_for_agent",
|
'\n' +
|
||||||
'Update task status using:',
|
wrapAgentBlock(
|
||||||
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
|
[
|
||||||
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
'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, {
|
sendInboxMessage(paths, teamName, {
|
||||||
to: task.owner,
|
to: task.owner,
|
||||||
|
|
@ -1377,11 +1389,14 @@ async function main() {
|
||||||
parts.push('\nDescription:\n' + String(task.description).slice(0, 500));
|
parts.push('\nDescription:\n' + String(task.description).slice(0, 500));
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
'\n' + "```info_for_agent",
|
'\n' +
|
||||||
'Update task status using:',
|
wrapAgentBlock(
|
||||||
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
|
[
|
||||||
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
'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, {
|
sendInboxMessage(paths, teamName, {
|
||||||
to: effectiveOwner,
|
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', () => {
|
it('persists full inbox metadata through controller messages.sendMessage', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
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):
|
return `Agent-only formatting policy (applies to ALL messages you write):
|
||||||
- Humans can see teammate inbox messages and coordination text in the UI.
|
- Humans can see teammate inbox messages and coordination text in the UI.
|
||||||
- Keep normal reasoning, decisions, and user-facing communication OUTSIDE agent-only blocks.
|
- 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:
|
- 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
|
- how to use internal MCP tools, exact tool names, and argument shapes
|
||||||
- review command phrases like "review_approve" / "review_request_changes"
|
- review command phrases like "review_approve" / "review_request_changes"
|
||||||
- internal file paths under ~/.claude/ (teams, tasks, kanban state, etc.)
|
- 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."
|
- 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_OPEN is exactly: ${AGENT_BLOCK_OPEN}
|
||||||
- AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}
|
- 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):
|
- Example (copy/paste exactly, no indentation):
|
||||||
${AGENT_BLOCK_OPEN}
|
${AGENT_BLOCK_OPEN}
|
||||||
(internal instructions: commands, script usage, paths, etc.)
|
(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">
|
<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>
|
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
|
||||||
<div
|
<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"
|
role="group"
|
||||||
aria-label="Group by"
|
aria-label="Group by"
|
||||||
>
|
>
|
||||||
|
|
@ -509,7 +509,7 @@ export const GlobalTaskList = ({
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-2 py-0.5 transition-colors',
|
'rounded px-2 py-0.5 transition-colors',
|
||||||
groupingMode === mode
|
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'
|
: 'text-text-muted hover:text-text-secondary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1551,17 +1551,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PendingRepliesBlock
|
<div className="mb-[35px]">
|
||||||
members={data.members}
|
<PendingRepliesBlock
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
members={data.members}
|
||||||
onMemberClick={setSelectedMember}
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
/>
|
onMemberClick={setSelectedMember}
|
||||||
<ActiveTasksBlock
|
/>
|
||||||
members={data.members}
|
<ActiveTasksBlock
|
||||||
tasks={data.tasks}
|
members={data.members}
|
||||||
onMemberClick={setSelectedMember}
|
tasks={data.tasks}
|
||||||
onTaskClick={setSelectedTask}
|
onMemberClick={setSelectedMember}
|
||||||
/>
|
onTaskClick={setSelectedTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ActivityTimeline
|
<ActivityTimeline
|
||||||
messages={filteredMessages}
|
messages={filteredMessages}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export const ActiveTasksBlock = ({
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={`${member.name}-${taskId}`}
|
key={`${member.name}-${taskId}`}
|
||||||
className="overflow-hidden rounded-md"
|
className="activity-card-enter-animate overflow-hidden rounded-md"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: CARD_BG,
|
backgroundColor: CARD_BG,
|
||||||
border: CARD_BORDER_STYLE,
|
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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
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 { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -47,7 +46,7 @@ export const PendingRepliesBlock = ({
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={`pending-reply:${member.name}:${sentAtMs}`}
|
key={`pending-reply:${member.name}:${sentAtMs}`}
|
||||||
className="overflow-hidden rounded-md"
|
className="activity-card-enter-animate overflow-hidden rounded-md"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: CARD_BG,
|
backgroundColor: CARD_BG,
|
||||||
border: CARD_BORDER_STYLE,
|
border: CARD_BORDER_STYLE,
|
||||||
|
|
@ -55,10 +54,18 @@ export const PendingRepliesBlock = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<Loader2
|
<span className="relative inline-flex shrink-0">
|
||||||
className="size-3.5 shrink-0 animate-spin"
|
<img
|
||||||
style={{ color: colors.border }}
|
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 ? (
|
{onMemberClick ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ export const AddMemberDialog = ({
|
||||||
setError(null);
|
setError(null);
|
||||||
const wf = workflowDraft.value.trim() || undefined;
|
const wf = workflowDraft.value.trim() || undefined;
|
||||||
onAdd(name.trim().toLowerCase(), effectiveRole, wf);
|
onAdd(name.trim().toLowerCase(), effectiveRole, wf);
|
||||||
|
// Reset form fields after successful submission
|
||||||
|
setName('');
|
||||||
|
setRoleSelect(NO_ROLE);
|
||||||
|
setCustomRole('');
|
||||||
workflowDraft.clearDraft();
|
workflowDraft.clearDraft();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -132,63 +136,67 @@ export const AddMemberDialog = ({
|
||||||
<DialogDescription>Add a new member to {teamName}</DialogDescription>
|
<DialogDescription>Add a new member to {teamName}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<form
|
||||||
<div className="space-y-2">
|
onSubmit={(e) => {
|
||||||
<Label>Name</Label>
|
e.preventDefault();
|
||||||
<Input
|
handleSubmit();
|
||||||
placeholder="e.g. alice, dev-1"
|
}}
|
||||||
value={name}
|
>
|
||||||
onChange={(e) => {
|
<div className="space-y-4 py-2">
|
||||||
setName(e.target.value);
|
<div className="space-y-2">
|
||||||
setError(null);
|
<Label>Name</Label>
|
||||||
}}
|
<Input
|
||||||
onKeyDown={(e) => {
|
placeholder="e.g. alice, dev-1"
|
||||||
if (e.key === 'Enter') handleSubmit();
|
value={name}
|
||||||
}}
|
onChange={(e) => {
|
||||||
autoFocus
|
setName(e.target.value);
|
||||||
/>
|
setError(null);
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
}}
|
||||||
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<DialogFooter>
|
||||||
<Label className="label-optional">Role (optional)</Label>
|
<Button type="button" variant="outline" onClick={onClose} disabled={adding}>
|
||||||
<RoleSelect
|
Cancel
|
||||||
value={roleSelect}
|
</Button>
|
||||||
onValueChange={setRoleSelect}
|
<Button type="submit" disabled={adding || !name.trim()}>
|
||||||
customRole={customRole}
|
{adding ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : null}
|
||||||
onCustomRoleChange={setCustomRole}
|
Add
|
||||||
/>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
<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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ export const TaskDetailDialog = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<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>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
<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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||||
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
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 { format } from 'date-fns';
|
||||||
import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
|
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. */
|
/** Extract agent-only instruction blocks and human-visible text from a message. */
|
||||||
function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[] } {
|
function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[] } {
|
||||||
const agentInfo: string[] = [];
|
const agentInfo = extractAgentBlockContents(raw);
|
||||||
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 humanText = stripAgentBlocks(raw);
|
const humanText = stripAgentBlocks(raw);
|
||||||
return { humanText, agentInfo };
|
return { humanText, agentInfo };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -620,7 +620,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
const rotatingTips = React.useMemo(
|
const rotatingTips = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
'Tip: Use @ to mention team members or search files',
|
'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",
|
"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;
|
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 {
|
@keyframes att-scale-in {
|
||||||
from {
|
from {
|
||||||
transform: scale(0);
|
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)
|
* Content wrapped in these markers is intended for the agent (Claude Code)
|
||||||
* and should be hidden from the human user in the UI.
|
* and should be hidden from the human user in the UI.
|
||||||
*
|
*
|
||||||
* Format:
|
* Canonical format:
|
||||||
* ```info_for_agent
|
* <info_for_agent>
|
||||||
* ... agent-only instructions ...
|
* ... 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_TAG = 'info_for_agent';
|
||||||
export const AGENT_BLOCK_OPEN = '```' + AGENT_BLOCK_TAG;
|
export const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
|
||||||
export const AGENT_BLOCK_CLOSE = '```';
|
export const AGENT_BLOCK_CLOSE = `</${AGENT_BLOCK_TAG}>`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regex pattern string for matching ``` info_for_agent ... ``` blocks (including fences).
|
* Regex pattern string for matching current and legacy agent-only blocks.
|
||||||
* Supports optional leading/trailing whitespace and newlines around the block.
|
|
||||||
*/
|
*/
|
||||||
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.
|
* 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 {
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
|
|
||||||
let tempClaudeRoot = '';
|
let tempClaudeRoot = '';
|
||||||
let tempTeamsBase = '';
|
let tempTeamsBase = '';
|
||||||
let tempTasksBase = '';
|
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('Default to working ONE task at a time');
|
||||||
expect(prompt).toContain('task_start');
|
expect(prompt).toContain('task_start');
|
||||||
expect(prompt).toContain('task_complete');
|
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('teamctl.js');
|
||||||
expect(prompt).not.toContain('.claude/tools');
|
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('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('Do NOT start the next task until the current task is completed');
|
||||||
expect(prompt).toContain('task_start');
|
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('teamctl.js');
|
||||||
expect(prompt).not.toContain('.claude/tools');
|
expect(prompt).not.toContain('.claude/tools');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2540,7 +2540,7 @@ describe('teamctl.js', () => {
|
||||||
]);
|
]);
|
||||||
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||||||
const text = String(inbox[0].text);
|
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('info_for_agent');
|
||||||
expect(text).toContain('task start');
|
expect(text).toContain('task start');
|
||||||
expect(text).toContain('task complete');
|
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