feat: implement action mode handling in messaging components

- Added action mode validation in cross-team and team messaging handlers to ensure only valid modes ('do', 'ask', 'delegate') are accepted.
- Enhanced MessageComposer and TeamDetailView to support action mode selection and pass it through to message sending functions.
- Introduced utility functions to build action mode-specific message content for better clarity in communication.
- Updated tests to cover action mode scenarios, including validation and handling of invalid modes.
- Improved user experience by integrating action mode instructions into message delivery for leads.
This commit is contained in:
iliya 2026-03-10 12:33:50 +02:00
parent 2eb814bb70
commit 2d0d390442
14 changed files with 271 additions and 33 deletions

View file

@ -6,6 +6,7 @@ import {
} from '@preload/constants/ipcChannels'; } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isAgentActionMode } from '../services/team/actionModeInstructions';
import type { CrossTeamService } from '../services/team/CrossTeamService'; import type { CrossTeamService } from '../services/team/CrossTeamService';
import type { IpcMain, IpcMainInvokeEvent } from 'electron'; import type { IpcMain, IpcMainInvokeEvent } from 'electron';
import type { IpcResult } from '@shared/types'; import type { IpcResult } from '@shared/types';
@ -48,6 +49,9 @@ async function handleSend(
throw new Error('Invalid request'); throw new Error('Invalid request');
} }
const req = request as Record<string, unknown>; const req = request as Record<string, unknown>;
if (req.actionMode !== undefined && !isAgentActionMode(req.actionMode)) {
throw new Error('actionMode must be one of: do, ask, delegate');
}
return getService().send({ return getService().send({
fromTeam: String(req.fromTeam ?? ''), fromTeam: String(req.fromTeam ?? ''),
fromMember: String(req.fromMember ?? ''), fromMember: String(req.fromMember ?? ''),
@ -56,6 +60,7 @@ async function handleSend(
replyToConversationId: replyToConversationId:
typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined, typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined,
text: String(req.text ?? ''), text: String(req.text ?? ''),
actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined,
summary: typeof req.summary === 'string' ? req.summary : undefined, summary: typeof req.summary === 'string' ? req.summary : undefined,
chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined, chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,
}); });

View file

@ -74,6 +74,10 @@ import * as path from 'path';
import { ConfigManager } from '../services/infrastructure/ConfigManager'; import { ConfigManager } from '../services/infrastructure/ConfigManager';
import { NotificationManager } from '../services/infrastructure/NotificationManager'; import { NotificationManager } from '../services/infrastructure/NotificationManager';
import {
buildActionModeAgentBlock,
isAgentActionMode,
} from '../services/team/actionModeInstructions';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver'; import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
@ -93,6 +97,7 @@ import type {
TeamProvisioningService, TeamProvisioningService,
} from '../services'; } from '../services';
import type { import type {
AgentActionMode,
AttachmentFileData, AttachmentFileData,
AttachmentMeta, AttachmentMeta,
AttachmentPayload, AttachmentPayload,
@ -986,6 +991,37 @@ function validateAttachments(
return { valid: true, value: result }; return { valid: true, value: result };
} }
function buildMessageDeliveryText(
baseText: string,
opts: {
actionMode?: AgentActionMode;
isLeadRecipient: boolean;
}
): string {
const hiddenBlocks: string[] = [];
const actionModeBlock = buildActionModeAgentBlock(opts.actionMode);
if (actionModeBlock) {
hiddenBlocks.push(actionModeBlock);
}
if (!opts.isLeadRecipient) {
hiddenBlocks.push(
[
AGENT_BLOCK_OPEN,
'You received a direct message from the human user via the UI.',
'Please reply back to recipient "user" with a short, human-readable answer.',
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
AGENT_BLOCK_CLOSE,
].join('\n')
);
}
if (hiddenBlocks.length === 0) {
return baseText;
}
return [...hiddenBlocks, baseText].join('\n\n');
}
async function handleSendMessage( async function handleSendMessage(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,
@ -1017,6 +1053,9 @@ async function handleSendMessage(
return { success: false, error: validatedFrom.error ?? 'Invalid from' }; return { success: false, error: validatedFrom.error ?? 'Invalid from' };
} }
} }
if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) {
return { success: false, error: 'actionMode must be one of: do, ask, delegate' };
}
let validatedAttachments: AttachmentPayload[] | undefined; let validatedAttachments: AttachmentPayload[] | undefined;
if ( if (
@ -1031,14 +1070,41 @@ async function handleSendMessage(
validatedAttachments = attResult.value; validatedAttachments = attResult.value;
} }
const tn = validatedTeamName.value!;
const memberName = validatedMember.value!;
let prevalidatedLeadName: string | null | undefined;
let prevalidatedIsLeadRecipient: boolean | undefined;
if (payload.actionMode === 'delegate') {
try {
prevalidatedLeadName = await getTeamDataService().getLeadMemberName(tn);
} catch (error) {
return wrapTeamHandler('sendMessage', async () => {
throw error;
});
}
prevalidatedIsLeadRecipient =
prevalidatedLeadName !== null && memberName === prevalidatedLeadName;
if (!prevalidatedIsLeadRecipient) {
return {
success: false,
error: 'Delegate mode is only supported when messaging the team lead',
};
}
}
return wrapTeamHandler('sendMessage', async () => { return wrapTeamHandler('sendMessage', async () => {
const tn = validatedTeamName.value!;
const provisioning = getTeamProvisioningService(); const provisioning = getTeamProvisioningService();
const isAlive = provisioning.isTeamAlive(tn); const isAlive = provisioning.isTeamAlive(tn);
const leadName = await getTeamDataService().getLeadMemberName(tn); const leadName =
const memberName = validatedMember.value!; prevalidatedLeadName !== undefined
const isLeadRecipient = leadName !== null && memberName === leadName; ? prevalidatedLeadName
: await getTeamDataService().getLeadMemberName(tn);
const isLeadRecipient =
prevalidatedIsLeadRecipient !== undefined
? prevalidatedIsLeadRecipient
: leadName !== null && memberName === leadName;
const actionMode = payload.actionMode;
// Attachments only supported for live lead (stdin content blocks) // Attachments only supported for live lead (stdin content blocks)
if (validatedAttachments?.length && (!isLeadRecipient || !isAlive)) { if (validatedAttachments?.length && (!isLeadRecipient || !isAlive)) {
@ -1049,6 +1115,7 @@ async function handleSendMessage(
// Smart routing: lead + alive → stdin direct, else → inbox // Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) { if (isLeadRecipient && isAlive) {
const resolvedLeadName = leadName ?? memberName;
// Separate try blocks: stdin delivery vs persistence // Separate try blocks: stdin delivery vs persistence
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate) // If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
// Wrap with instructions so lead responds with visible text (not just agent-only blocks) // Wrap with instructions so lead responds with visible text (not just agent-only blocks)
@ -1057,7 +1124,10 @@ async function handleSendMessage(
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`, `IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
``, ``,
`Message from user:`, `Message from user:`,
payload.text!, buildMessageDeliveryText(payload.text!, {
actionMode,
isLeadRecipient: true,
}),
].join('\n'); ].join('\n');
let stdinSent = false; let stdinSent = false;
@ -1090,7 +1160,7 @@ async function handleSendMessage(
try { try {
result = await getTeamDataService().sendDirectToLead( result = await getTeamDataService().sendDirectToLead(
tn, tn,
leadName, resolvedLeadName,
payload.text!, payload.text!,
payload.summary, payload.summary,
attachmentMeta attachmentMeta
@ -1109,7 +1179,7 @@ async function handleSendMessage(
provisioning.pushLiveLeadProcessMessage(tn, { provisioning.pushLiveLeadProcessMessage(tn, {
from: 'user', from: 'user',
to: leadName, to: resolvedLeadName,
text: payload.text!, text: payload.text!,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
read: true, read: true,
@ -1125,17 +1195,10 @@ async function handleSendMessage(
// Inbox path: offline lead or regular members (no attachment support) // Inbox path: offline lead or regular members (no attachment support)
const baseText = payload.text!.trim(); const baseText = payload.text!.trim();
const memberDeliveryText = isLeadRecipient const memberDeliveryText = buildMessageDeliveryText(baseText, {
? baseText actionMode,
: [ isLeadRecipient,
baseText, });
'',
AGENT_BLOCK_OPEN,
'You received a direct message from the human user via the UI.',
'Please reply back to recipient "user" with a short, human-readable answer.',
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
AGENT_BLOCK_CLOSE,
].join('\n');
const result = await getTeamDataService().sendMessage(tn, { const result = await getTeamDataService().sendMessage(tn, {
member: memberName, member: memberName,
text: memberDeliveryText, text: memberDeliveryText,

View file

@ -4,6 +4,7 @@ import { createLogger } from '@shared/utils/logger';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import { buildActionModeAgentBlock } from './actionModeInstructions';
import { CascadeGuard } from './CascadeGuard'; import { CascadeGuard } from './CascadeGuard';
import { CrossTeamOutbox } from './CrossTeamOutbox'; import { CrossTeamOutbox } from './CrossTeamOutbox';
@ -43,7 +44,7 @@ export class CrossTeamService {
) {} ) {}
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> { async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
const { fromTeam, fromMember, toTeam, text, summary } = request; const { fromTeam, fromMember, toTeam, text, summary, actionMode } = request;
const chainDepth = request.chainDepth ?? 0; const chainDepth = request.chainDepth ?? 0;
const messageId = request.messageId?.trim() || randomUUID(); const messageId = request.messageId?.trim() || randomUUID();
const timestamp = request.timestamp ?? new Date().toISOString(); const timestamp = request.timestamp ?? new Date().toISOString();
@ -88,7 +89,9 @@ export class CrossTeamService {
// 3. Format // 3. Format
const from = `${fromTeam}.${fromMember}`; const from = `${fromTeam}.${fromMember}`;
const formattedText = formatCrossTeamText(from, chainDepth, text, { const actionModeBlock = buildActionModeAgentBlock(actionMode);
const deliveryText = actionModeBlock ? `${actionModeBlock}\n\n${text}` : text;
const formattedText = formatCrossTeamText(from, chainDepth, deliveryText, {
conversationId, conversationId,
replyToConversationId, replyToConversationId,
}); });

View file

@ -44,6 +44,7 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite'; import { atomicWriteAsync } from './atomicWrite';
import { buildActionModeProtocol } from './actionModeInstructions';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { withFileLock } from './fileLock'; import { withFileLock } from './fileLock';
import { withInboxLock } from './inboxLock'; import { withInboxLock } from './inboxLock';
@ -362,6 +363,7 @@ function buildMemberSpawnPrompt(
const workflowBlock = member.workflow?.trim() const workflowBlock = member.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
: ''; : '';
const actionModeProtocol = buildActionModeProtocol();
return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock} return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock}
${getAgentLanguageInstruction()} ${getAgentLanguageInstruction()}
@ -369,6 +371,7 @@ Introduce yourself briefly (name and role) and confirm you are ready.
Then wait for task assignments. Then wait for task assignments.
When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
${buildTeammateAgentBlockReminder()} ${buildTeammateAgentBlockReminder()}
${actionModeProtocol}
Include the following agent-only instructions verbatim in the prompt: Include the following agent-only instructions verbatim in the prompt:
${taskProtocol} ${taskProtocol}
@ -531,6 +534,7 @@ function buildPersistentLeadContext(opts: {
const { teamName, leadName, isSolo, members, compact } = opts; const { teamName, leadName, isSolo, members, compact } = opts;
const languageInstruction = getAgentLanguageInstruction(); const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy(); const agentBlockPolicy = buildAgentBlockUsagePolicy();
const actionModeProtocol = buildActionModeProtocol();
const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName); const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName);
const soloConstraint = isSolo const soloConstraint = isSolo
@ -575,6 +579,8 @@ Constraints:
${teamCtlOps} ${teamCtlOps}
${actionModeProtocol}
Communication protocol (CRITICAL you are running headless, no one sees your text output): Communication protocol (CRITICAL you are running headless, no one sees your text output):
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. - When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
- Your plain text output is invisible to teammates they are separate processes and can only read their inbox. - Your plain text output is invisible to teammates they are separate processes and can only read their inbox.
@ -844,6 +850,7 @@ function buildLaunchPrompt(
const workflowBlock = m.workflow?.trim() const workflowBlock = m.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(m.workflow, ' ')}` ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(m.workflow, ' ')}`
: ''; : '';
const actionModeProtocol = indentMultiline(buildActionModeProtocol(), ' ');
return ` For "${m.name}": return ` For "${m.name}":
- prompt: - prompt:
@ -853,6 +860,7 @@ function buildLaunchPrompt(
The team has been reconnected after a restart. The team has been reconnected after a restart.
${hasTasks ? `You may have assigned tasks in states like in_progress, needsFix, pending, review, completed, or approved from the previous session.` : 'You have no assigned tasks currently.'} ${hasTasks ? `You may have assigned tasks in states like in_progress, needsFix, pending, review, completed, or approved from the previous session.` : 'You have no assigned tasks currently.'}
${buildTeammateAgentBlockReminder()} ${buildTeammateAgentBlockReminder()}
${actionModeProtocol}
Your FIRST action: call MCP tool task_briefing with: Your FIRST action: call MCP tool task_briefing with:
{ teamName: "${request.teamName}", memberName: "${m.name}" } { teamName: "${request.teamName}", memberName: "${m.name}" }

View file

@ -0,0 +1,52 @@
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import type { AgentActionMode } from '@shared/types';
const ACTION_MODE_BLOCKS: Record<AgentActionMode, string[]> = {
do: [
'TURN ACTION MODE: DO',
'- This turn is full-execution mode.',
'- You may discuss, read, edit files, change state, run commands/tools, and delegate if useful.',
'- No extra restrictions apply beyond your normal system/team rules.',
],
ask: [
'TURN ACTION MODE: ASK',
'- This turn is STRICTLY read-only conversation mode.',
'- ALLOWED: read/analyze/explain, answer questions, discuss options, and request clarification if needed.',
'- FORBIDDEN: editing files, changing code, changing task/board state, delegating work, running commands/scripts/tools with side effects, or causing any non-communication state change.',
],
delegate: [
'TURN ACTION MODE: DELEGATE',
'- This turn is STRICTLY delegation/orchestration mode.',
'- If you are the team lead, decompose the work, create/assign tasks, coordinate teammates, and monitor progress.',
'- FORBIDDEN: implementing the work yourself, editing files yourself, running state-changing/code-changing commands yourself, or taking direct execution ownership unless you are truly in SOLO MODE.',
'- If you are not the lead or no delegation target exists, do not execute the work yourself; explain the limitation briefly and request a different mode or a lead handoff.',
],
};
export function buildActionModeProtocol(): string {
return [
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):',
'- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.',
'- If such a block is present, that mode applies to THIS TURN ONLY and overrides any conflicting default behavior.',
'- Never silently broaden permissions beyond the selected mode.',
'- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.',
'- Modes:',
' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.',
' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.',
' - DELEGATE: Strict orchestration mode for leads. Delegate the work to teammates and coordinate it, but do not implement it yourself unless you are truly in SOLO MODE.',
].join('\n');
}
export function buildActionModeAgentBlock(mode: AgentActionMode | undefined): string {
if (!mode) {
return '';
}
const lines = ACTION_MODE_BLOCKS[mode];
return `${AGENT_BLOCK_OPEN}\n${lines.join('\n')}\n${AGENT_BLOCK_CLOSE}`;
}
export function isAgentActionMode(value: unknown): value is AgentActionMode {
return value === 'do' || value === 'ask' || value === 'delegate';
}

View file

@ -646,6 +646,7 @@ const ProjectsGrid = ({
return ( return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{!searchQuery.trim() && <NewProjectCard />}
{filteredRepos.map((repo) => { {filteredRepos.map((repo) => {
const counts = repo.worktrees.reduce( const counts = repo.worktrees.reduce(
(acc, wt) => { (acc, wt) => {
@ -673,7 +674,6 @@ const ProjectsGrid = ({
/> />
); );
})} })}
{!searchQuery.trim() && <NewProjectCard />}
</div> </div>
); );
}; };

View file

@ -1564,10 +1564,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sending={sendingMessage} sending={sendingMessage}
sendError={sendMessageError} sendError={sendMessageError}
lastResult={lastSendMessageResult} lastResult={lastSendMessageResult}
onSend={(member, text, summary, attachments) => { onSend={(member, text, summary, attachments, actionMode) => {
const sentAtMs = Date.now(); const sentAtMs = Date.now();
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => { void sendTeamMessage(teamName, {
member,
text,
summary,
attachments,
actionMode,
}).catch(() => {
setPendingRepliesByMember((prev) => { setPendingRepliesByMember((prev) => {
if (prev[member] !== sentAtMs) return prev; if (prev[member] !== sentAtMs) return prev;
const next = { ...prev }; const next = { ...prev };
@ -1576,12 +1582,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}); });
}); });
}} }}
onCrossTeamSend={(toTeam, text, summary) => { onCrossTeamSend={(toTeam, text, summary, actionMode) => {
void sendCrossTeamMessage({ void sendCrossTeamMessage({
fromTeam: teamName, fromTeam: teamName,
fromMember: 'user', fromMember: 'user',
toTeam, toTeam,
text, text,
actionMode,
summary, summary,
}); });
}} }}

View file

@ -5,8 +5,9 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import type { AgentActionMode } from '@shared/types';
export type ActionMode = 'do' | 'ask' | 'delegate'; export type ActionMode = AgentActionMode;
interface ActionModeSelectorProps { interface ActionModeSelectorProps {
value: ActionMode; value: ActionMode;
@ -24,21 +25,21 @@ const MODE_CONFIG: {
{ {
mode: 'do', mode: 'do',
label: 'Do', label: 'Do',
tooltip: 'Execute the task independently', tooltip: 'Full execution mode - can change code/state, run commands, or delegate',
activeClass: 'bg-rose-500/80 text-white', activeClass: 'bg-rose-500/80 text-white',
tooltipClass: 'bg-rose-500/80 border-rose-600 text-white', tooltipClass: 'bg-rose-500/80 border-rose-600 text-white',
}, },
{ {
mode: 'ask', mode: 'ask',
label: 'Ask', label: 'Ask',
tooltip: 'Chat only — no file changes or commands', tooltip: 'Read-only discussion mode - no code/state changes or commands',
activeClass: 'bg-blue-600 text-white', activeClass: 'bg-blue-600 text-white',
tooltipClass: 'bg-blue-600 border-blue-700 text-white', tooltipClass: 'bg-blue-600 border-blue-700 text-white',
}, },
{ {
mode: 'delegate', mode: 'delegate',
label: 'Delegate', label: 'Delegate',
tooltip: 'Delegate task to a teammate (lead only)', tooltip: 'Lead-only orchestration - delegate everything, do not execute yourself',
activeClass: 'bg-amber-500/80 text-white', activeClass: 'bg-amber-500/80 text-white',
tooltipClass: 'bg-amber-500/80 border-amber-600 text-white', tooltipClass: 'bg-amber-500/80 border-amber-600 text-white',
}, },

View file

@ -33,9 +33,15 @@ interface MessageComposerProps {
recipient: string, recipient: string,
text: string, text: string,
summary?: string, summary?: string,
attachments?: AttachmentPayload[] attachments?: AttachmentPayload[],
actionMode?: ActionMode
) => void;
onCrossTeamSend?: (
toTeam: string,
text: string,
summary?: string,
actionMode?: ActionMode
) => void; ) => void;
onCrossTeamSend?: (toTeam: string, text: string, summary?: string) => void;
} }
export const MessageComposer = ({ export const MessageComposer = ({
@ -131,6 +137,7 @@ export const MessageComposer = ({
const selectedMember = members.find((m) => m.name === recipient); const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const canDelegate = isCrossTeam || isLeadRecipient;
// Auto-select delegate when lead recipient changes, reset when non-lead // Auto-select delegate when lead recipient changes, reset when non-lead
useEffect(() => { useEffect(() => {
@ -165,17 +172,19 @@ export const MessageComposer = ({
pendingSendRef.current = true; pendingSendRef.current = true;
const serialized = serializeChipsWithText(trimmed, draft.chips); const serialized = serializeChipsWithText(trimmed, draft.chips);
if (isCrossTeam && selectedTeam && onCrossTeamSend) { if (isCrossTeam && selectedTeam && onCrossTeamSend) {
onCrossTeamSend(selectedTeam, serialized, trimmed); onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode);
} else { } else {
// Summary should stay compact (no expanded chip markdown) // Summary should stay compact (no expanded chip markdown)
onSend( onSend(
recipient, recipient,
serialized, serialized,
trimmed, trimmed,
draft.attachments.length > 0 ? draft.attachments : undefined draft.attachments.length > 0 ? draft.attachments : undefined,
actionMode
); );
} }
}, [ }, [
actionMode,
canSend, canSend,
recipient, recipient,
trimmed, trimmed,
@ -508,6 +517,7 @@ export const MessageComposer = ({
color={selectedResolvedColor} color={selectedResolvedColor}
size="sm" size="sm"
hideAvatar={recipient === 'user'} hideAvatar={recipient === 'user'}
disableHoverCard
/> />
) : ( ) : (
<span className="text-[var(--color-text-muted)]">Select...</span> <span className="text-[var(--color-text-muted)]">Select...</span>
@ -582,6 +592,7 @@ export const MessageComposer = ({
color={resolvedColor} color={resolvedColor}
size="sm" size="sm"
hideAvatar={m.name === 'user'} hideAvatar={m.name === 'user'}
disableHoverCard
/> />
{role ? ( {role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]"> <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
@ -612,6 +623,7 @@ export const MessageComposer = ({
color={selectedResolvedColor} color={selectedResolvedColor}
size="sm" size="sm"
hideAvatar={recipient === 'user'} hideAvatar={recipient === 'user'}
disableHoverCard
/> />
) : ( ) : (
<span className="text-[var(--color-text-muted)]">Select...</span> <span className="text-[var(--color-text-muted)]">Select...</span>
@ -686,6 +698,7 @@ export const MessageComposer = ({
color={resolvedColor} color={resolvedColor}
size="sm" size="sm"
hideAvatar={m.name === 'user'} hideAvatar={m.name === 'user'}
disableHoverCard
/> />
{role ? ( {role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]"> <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
@ -732,7 +745,7 @@ export const MessageComposer = ({
<ActionModeSelector <ActionModeSelector
value={actionMode} value={actionMode}
onChange={setActionMode} onChange={setActionMode}
showDelegate={isLeadRecipient} showDelegate={canDelegate}
/> />
} }
cornerAction={ cornerAction={

View file

@ -268,9 +268,12 @@ export interface InboxMessage {
toolCalls?: ToolCallMeta[]; toolCalls?: ToolCallMeta[];
} }
export type AgentActionMode = 'do' | 'ask' | 'delegate';
export interface SendMessageRequest { export interface SendMessageRequest {
member: string; member: string;
text: string; text: string;
actionMode?: AgentActionMode;
summary?: string; summary?: string;
from?: string; from?: string;
timestamp?: string; timestamp?: string;
@ -641,6 +644,7 @@ export interface CrossTeamSendRequest {
conversationId?: string; conversationId?: string;
replyToConversationId?: string; replyToConversationId?: string;
text: string; text: string;
actionMode?: AgentActionMode;
summary?: string; summary?: string;
chainDepth?: number; chainDepth?: number;
} }

View file

@ -76,6 +76,7 @@ describe('crossTeam IPC handlers', () => {
fromMember: 'lead', fromMember: 'lead',
toTeam: 'team-b', toTeam: 'team-b',
text: 'Hello', text: 'Hello',
actionMode: 'delegate',
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -87,11 +88,30 @@ describe('crossTeam IPC handlers', () => {
fromMember: 'lead', fromMember: 'lead',
toTeam: 'team-b', toTeam: 'team-b',
text: 'Hello', text: 'Hello',
actionMode: 'delegate',
summary: undefined, summary: undefined,
chainDepth: undefined, chainDepth: undefined,
}); });
}); });
it('send handler rejects invalid actionMode', async () => {
registerCrossTeamHandlers(mockIpc as never);
const handler = mockIpc.handle.mock.calls.find((c) => c[0] === 'cross-team:send')![1];
const result = await handler({} as never, {
fromTeam: 'team-a',
fromMember: 'lead',
toTeam: 'team-b',
text: 'Hello',
actionMode: 'break-everything',
});
expect(result).toEqual({
success: false,
error: 'actionMode must be one of: do, ask, delegate',
});
});
it('send handler returns error on service throw', async () => { it('send handler returns error on service throw', async () => {
mockService.send.mockRejectedValue(new Error('Target team not found')); mockService.send.mockRejectedValue(new Error('Target team not found'));

View file

@ -107,7 +107,9 @@ describe('ipc teams handlers', () => {
})), })),
reconcileTeamArtifacts: vi.fn(async () => undefined), reconcileTeamArtifacts: vi.fn(async () => undefined),
deleteTeam: vi.fn(async () => undefined), deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'),
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })), sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })), createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
requestReview: vi.fn(async () => undefined), requestReview: vi.fn(async () => undefined),
updateKanban: vi.fn(async () => undefined), updateKanban: vi.fn(async () => undefined),
@ -153,6 +155,7 @@ describe('ipc teams handlers', () => {
launchTeam: vi.fn(async () => ({ runId: 'run-2' })), launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
sendMessageToTeam: vi.fn(async () => undefined), sendMessageToTeam: vi.fn(async () => undefined),
isTeamAlive: vi.fn(() => true), isTeamAlive: vi.fn(() => true),
pushLiveLeadProcessMessage: vi.fn(),
relayLeadInboxMessages: vi.fn(async () => 0), relayLeadInboxMessages: vi.fn(async () => 0),
relayMemberInboxMessages: vi.fn(async () => 0), relayMemberInboxMessages: vi.fn(async () => 0),
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]), getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
@ -230,6 +233,45 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it('passes hidden ask-mode instructions to a live lead without exposing them in stored text', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: 'Can you review the approach?',
actionMode: 'ask',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('TURN ACTION MODE: ASK'),
undefined
);
expect(service.sendDirectToLead).toHaveBeenCalledWith(
'my-team',
'team-lead',
'Can you review the approach?',
undefined,
undefined
);
});
it('rejects delegate mode when recipient is not the team lead', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'alice',
text: 'Take this on',
actionMode: 'delegate',
})) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toBe('Delegate mode is only supported when messaging the team lead');
});
it('calls service and returns success on happy paths', async () => { it('calls service and returns success on happy paths', async () => {
const listResult = (await handlers.get(TEAM_LIST)!({} as never)) as { const listResult = (await handlers.get(TEAM_LIST)!({} as never)) as {
success: boolean; success: boolean;

View file

@ -108,6 +108,20 @@ describe('CrossTeamService', () => {
expect(prefix?.conversationId).toBeTruthy(); expect(prefix?.conversationId).toBeTruthy();
}); });
it('injects a hidden action-mode block for the target lead only', async () => {
await service.send(makeRequest({ actionMode: 'ask', text: 'Can you inspect this?' }));
const [, req] = inboxWriter.sendMessage.mock.calls[0];
expect(req.text).toContain('TURN ACTION MODE: ASK');
expect(req.text).toContain('STRICTLY read-only conversation mode');
await vi.waitFor(() => {
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
});
const [, senderReq] = inboxWriter.sendMessage.mock.calls[1];
expect(senderReq.text).toBe('Can you inspect this?');
});
it('writes sender copy to fromTeam inbox as user_sent', async () => { it('writes sender copy to fromTeam inbox as user_sent', async () => {
await service.send(makeRequest()); await service.send(makeRequest());

View file

@ -114,6 +114,9 @@ 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('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(prompt).toContain('ASK: Strict read-only conversation mode.');
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
expect(prompt).not.toContain('teamctl.js'); expect(prompt).not.toContain('teamctl.js');
@ -218,6 +221,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".'); expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(prompt).toContain('DO: Full execution mode.');
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');
expect(prompt).toContain('you MUST do ALL steps below'); expect(prompt).toContain('you MUST do ALL steps below');
expect(prompt).toContain('STEP 2 — THEN, add a task comment describing exactly what you need'); expect(prompt).toContain('STEP 2 — THEN, add a task comment describing exactly what you need');
expect(prompt).toContain('STEP 3 — THEN, send a message to your team lead via SendMessage'); expect(prompt).toContain('STEP 3 — THEN, send a message to your team lead via SendMessage');