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:
parent
2eb814bb70
commit
2d0d390442
14 changed files with 271 additions and 33 deletions
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { isAgentActionMode } from '../services/team/actionModeInstructions';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
import type { IpcResult } from '@shared/types';
|
||||
|
|
@ -48,6 +49,9 @@ async function handleSend(
|
|||
throw new Error('Invalid request');
|
||||
}
|
||||
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({
|
||||
fromTeam: String(req.fromTeam ?? ''),
|
||||
fromMember: String(req.fromMember ?? ''),
|
||||
|
|
@ -56,6 +60,7 @@ async function handleSend(
|
|||
replyToConversationId:
|
||||
typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined,
|
||||
text: String(req.text ?? ''),
|
||||
actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined,
|
||||
summary: typeof req.summary === 'string' ? req.summary : undefined,
|
||||
chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ import * as path from 'path';
|
|||
|
||||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||
import {
|
||||
buildActionModeAgentBlock,
|
||||
isAgentActionMode,
|
||||
} from '../services/team/actionModeInstructions';
|
||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
|
|
@ -93,6 +97,7 @@ import type {
|
|||
TeamProvisioningService,
|
||||
} from '../services';
|
||||
import type {
|
||||
AgentActionMode,
|
||||
AttachmentFileData,
|
||||
AttachmentMeta,
|
||||
AttachmentPayload,
|
||||
|
|
@ -986,6 +991,37 @@ function validateAttachments(
|
|||
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(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
@ -1017,6 +1053,9 @@ async function handleSendMessage(
|
|||
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;
|
||||
if (
|
||||
|
|
@ -1031,14 +1070,41 @@ async function handleSendMessage(
|
|||
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 () => {
|
||||
const tn = validatedTeamName.value!;
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
|
||||
const leadName = await getTeamDataService().getLeadMemberName(tn);
|
||||
const memberName = validatedMember.value!;
|
||||
const isLeadRecipient = leadName !== null && memberName === leadName;
|
||||
const leadName =
|
||||
prevalidatedLeadName !== undefined
|
||||
? 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)
|
||||
if (validatedAttachments?.length && (!isLeadRecipient || !isAlive)) {
|
||||
|
|
@ -1049,6 +1115,7 @@ async function handleSendMessage(
|
|||
|
||||
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
const resolvedLeadName = leadName ?? memberName;
|
||||
// Separate try blocks: stdin delivery vs persistence
|
||||
// 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)
|
||||
|
|
@ -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.`,
|
||||
``,
|
||||
`Message from user:`,
|
||||
payload.text!,
|
||||
buildMessageDeliveryText(payload.text!, {
|
||||
actionMode,
|
||||
isLeadRecipient: true,
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
let stdinSent = false;
|
||||
|
|
@ -1090,7 +1160,7 @@ async function handleSendMessage(
|
|||
try {
|
||||
result = await getTeamDataService().sendDirectToLead(
|
||||
tn,
|
||||
leadName,
|
||||
resolvedLeadName,
|
||||
payload.text!,
|
||||
payload.summary,
|
||||
attachmentMeta
|
||||
|
|
@ -1109,7 +1179,7 @@ async function handleSendMessage(
|
|||
|
||||
provisioning.pushLiveLeadProcessMessage(tn, {
|
||||
from: 'user',
|
||||
to: leadName,
|
||||
to: resolvedLeadName,
|
||||
text: payload.text!,
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
|
|
@ -1125,17 +1195,10 @@ async function handleSendMessage(
|
|||
|
||||
// Inbox path: offline lead or regular members (no attachment support)
|
||||
const baseText = payload.text!.trim();
|
||||
const memberDeliveryText = isLeadRecipient
|
||||
? baseText
|
||||
: [
|
||||
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 memberDeliveryText = buildMessageDeliveryText(baseText, {
|
||||
actionMode,
|
||||
isLeadRecipient,
|
||||
});
|
||||
const result = await getTeamDataService().sendMessage(tn, {
|
||||
member: memberName,
|
||||
text: memberDeliveryText,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { buildActionModeAgentBlock } from './actionModeInstructions';
|
||||
import { CascadeGuard } from './CascadeGuard';
|
||||
import { CrossTeamOutbox } from './CrossTeamOutbox';
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ export class CrossTeamService {
|
|||
) {}
|
||||
|
||||
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 messageId = request.messageId?.trim() || randomUUID();
|
||||
const timestamp = request.timestamp ?? new Date().toISOString();
|
||||
|
|
@ -88,7 +89,9 @@ export class CrossTeamService {
|
|||
|
||||
// 3. Format
|
||||
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,
|
||||
replyToConversationId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import * as os from 'os';
|
|||
import * as path from 'path';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { buildActionModeProtocol } from './actionModeInstructions';
|
||||
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||
import { withFileLock } from './fileLock';
|
||||
import { withInboxLock } from './inboxLock';
|
||||
|
|
@ -362,6 +363,7 @@ function buildMemberSpawnPrompt(
|
|||
const workflowBlock = member.workflow?.trim()
|
||||
? `\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}
|
||||
|
||||
${getAgentLanguageInstruction()}
|
||||
|
|
@ -369,6 +371,7 @@ Introduce yourself briefly (name and role) and confirm you are ready.
|
|||
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.
|
||||
${buildTeammateAgentBlockReminder()}
|
||||
${actionModeProtocol}
|
||||
Include the following agent-only instructions verbatim in the prompt:
|
||||
|
||||
${taskProtocol}
|
||||
|
|
@ -531,6 +534,7 @@ function buildPersistentLeadContext(opts: {
|
|||
const { teamName, leadName, isSolo, members, compact } = opts;
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
const actionModeProtocol = buildActionModeProtocol();
|
||||
const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName);
|
||||
|
||||
const soloConstraint = isSolo
|
||||
|
|
@ -575,6 +579,8 @@ Constraints:
|
|||
|
||||
${teamCtlOps}
|
||||
|
||||
${actionModeProtocol}
|
||||
|
||||
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.
|
||||
- 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()
|
||||
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(m.workflow, ' ')}`
|
||||
: '';
|
||||
const actionModeProtocol = indentMultiline(buildActionModeProtocol(), ' ');
|
||||
|
||||
return ` For "${m.name}":
|
||||
- prompt:
|
||||
|
|
@ -853,6 +860,7 @@ function buildLaunchPrompt(
|
|||
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.'}
|
||||
${buildTeammateAgentBlockReminder()}
|
||||
${actionModeProtocol}
|
||||
|
||||
Your FIRST action: call MCP tool task_briefing with:
|
||||
{ teamName: "${request.teamName}", memberName: "${m.name}" }
|
||||
|
|
|
|||
52
src/main/services/team/actionModeInstructions.ts
Normal file
52
src/main/services/team/actionModeInstructions.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -646,6 +646,7 @@ const ProjectsGrid = ({
|
|||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{!searchQuery.trim() && <NewProjectCard />}
|
||||
{filteredRepos.map((repo) => {
|
||||
const counts = repo.worktrees.reduce(
|
||||
(acc, wt) => {
|
||||
|
|
@ -673,7 +674,6 @@ const ProjectsGrid = ({
|
|||
/>
|
||||
);
|
||||
})}
|
||||
{!searchQuery.trim() && <NewProjectCard />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1564,10 +1564,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
lastResult={lastSendMessageResult}
|
||||
onSend={(member, text, summary, attachments) => {
|
||||
onSend={(member, text, summary, attachments, actionMode) => {
|
||||
const sentAtMs = Date.now();
|
||||
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||
void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => {
|
||||
void sendTeamMessage(teamName, {
|
||||
member,
|
||||
text,
|
||||
summary,
|
||||
attachments,
|
||||
actionMode,
|
||||
}).catch(() => {
|
||||
setPendingRepliesByMember((prev) => {
|
||||
if (prev[member] !== sentAtMs) return 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({
|
||||
fromTeam: teamName,
|
||||
fromMember: 'user',
|
||||
toTeam,
|
||||
text,
|
||||
actionMode,
|
||||
summary,
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import {
|
|||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import type { AgentActionMode } from '@shared/types';
|
||||
|
||||
export type ActionMode = 'do' | 'ask' | 'delegate';
|
||||
export type ActionMode = AgentActionMode;
|
||||
|
||||
interface ActionModeSelectorProps {
|
||||
value: ActionMode;
|
||||
|
|
@ -24,21 +25,21 @@ const MODE_CONFIG: {
|
|||
{
|
||||
mode: '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',
|
||||
tooltipClass: 'bg-rose-500/80 border-rose-600 text-white',
|
||||
},
|
||||
{
|
||||
mode: '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',
|
||||
tooltipClass: 'bg-blue-600 border-blue-700 text-white',
|
||||
},
|
||||
{
|
||||
mode: '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',
|
||||
tooltipClass: 'bg-amber-500/80 border-amber-600 text-white',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,9 +33,15 @@ interface MessageComposerProps {
|
|||
recipient: string,
|
||||
text: string,
|
||||
summary?: string,
|
||||
attachments?: AttachmentPayload[]
|
||||
attachments?: AttachmentPayload[],
|
||||
actionMode?: ActionMode
|
||||
) => void;
|
||||
onCrossTeamSend?: (
|
||||
toTeam: string,
|
||||
text: string,
|
||||
summary?: string,
|
||||
actionMode?: ActionMode
|
||||
) => void;
|
||||
onCrossTeamSend?: (toTeam: string, text: string, summary?: string) => void;
|
||||
}
|
||||
|
||||
export const MessageComposer = ({
|
||||
|
|
@ -131,6 +137,7 @@ export const MessageComposer = ({
|
|||
const selectedMember = members.find((m) => m.name === recipient);
|
||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||
const canDelegate = isCrossTeam || isLeadRecipient;
|
||||
|
||||
// Auto-select delegate when lead recipient changes, reset when non-lead
|
||||
useEffect(() => {
|
||||
|
|
@ -165,17 +172,19 @@ export const MessageComposer = ({
|
|||
pendingSendRef.current = true;
|
||||
const serialized = serializeChipsWithText(trimmed, draft.chips);
|
||||
if (isCrossTeam && selectedTeam && onCrossTeamSend) {
|
||||
onCrossTeamSend(selectedTeam, serialized, trimmed);
|
||||
onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode);
|
||||
} else {
|
||||
// Summary should stay compact (no expanded chip markdown)
|
||||
onSend(
|
||||
recipient,
|
||||
serialized,
|
||||
trimmed,
|
||||
draft.attachments.length > 0 ? draft.attachments : undefined
|
||||
draft.attachments.length > 0 ? draft.attachments : undefined,
|
||||
actionMode
|
||||
);
|
||||
}
|
||||
}, [
|
||||
actionMode,
|
||||
canSend,
|
||||
recipient,
|
||||
trimmed,
|
||||
|
|
@ -508,6 +517,7 @@ export const MessageComposer = ({
|
|||
color={selectedResolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={recipient === 'user'}
|
||||
disableHoverCard
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
||||
|
|
@ -582,6 +592,7 @@ export const MessageComposer = ({
|
|||
color={resolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={m.name === 'user'}
|
||||
disableHoverCard
|
||||
/>
|
||||
{role ? (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
|
|
@ -612,6 +623,7 @@ export const MessageComposer = ({
|
|||
color={selectedResolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={recipient === 'user'}
|
||||
disableHoverCard
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
||||
|
|
@ -686,6 +698,7 @@ export const MessageComposer = ({
|
|||
color={resolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={m.name === 'user'}
|
||||
disableHoverCard
|
||||
/>
|
||||
{role ? (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
|
|
@ -732,7 +745,7 @@ export const MessageComposer = ({
|
|||
<ActionModeSelector
|
||||
value={actionMode}
|
||||
onChange={setActionMode}
|
||||
showDelegate={isLeadRecipient}
|
||||
showDelegate={canDelegate}
|
||||
/>
|
||||
}
|
||||
cornerAction={
|
||||
|
|
|
|||
|
|
@ -268,9 +268,12 @@ export interface InboxMessage {
|
|||
toolCalls?: ToolCallMeta[];
|
||||
}
|
||||
|
||||
export type AgentActionMode = 'do' | 'ask' | 'delegate';
|
||||
|
||||
export interface SendMessageRequest {
|
||||
member: string;
|
||||
text: string;
|
||||
actionMode?: AgentActionMode;
|
||||
summary?: string;
|
||||
from?: string;
|
||||
timestamp?: string;
|
||||
|
|
@ -641,6 +644,7 @@ export interface CrossTeamSendRequest {
|
|||
conversationId?: string;
|
||||
replyToConversationId?: string;
|
||||
text: string;
|
||||
actionMode?: AgentActionMode;
|
||||
summary?: string;
|
||||
chainDepth?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ describe('crossTeam IPC handlers', () => {
|
|||
fromMember: 'lead',
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
actionMode: 'delegate',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
|
|
@ -87,11 +88,30 @@ describe('crossTeam IPC handlers', () => {
|
|||
fromMember: 'lead',
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
actionMode: 'delegate',
|
||||
summary: 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 () => {
|
||||
mockService.send.mockRejectedValue(new Error('Target team not found'));
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ describe('ipc teams handlers', () => {
|
|||
})),
|
||||
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
||||
deleteTeam: vi.fn(async () => undefined),
|
||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||
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' })),
|
||||
requestReview: vi.fn(async () => undefined),
|
||||
updateKanban: vi.fn(async () => undefined),
|
||||
|
|
@ -153,6 +155,7 @@ describe('ipc teams handlers', () => {
|
|||
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
|
||||
sendMessageToTeam: vi.fn(async () => undefined),
|
||||
isTeamAlive: vi.fn(() => true),
|
||||
pushLiveLeadProcessMessage: vi.fn(),
|
||||
relayLeadInboxMessages: vi.fn(async () => 0),
|
||||
relayMemberInboxMessages: vi.fn(async () => 0),
|
||||
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
||||
|
|
@ -230,6 +233,45 @@ describe('ipc teams handlers', () => {
|
|||
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 () => {
|
||||
const listResult = (await handlers.get(TEAM_LIST)!({} as never)) as {
|
||||
success: boolean;
|
||||
|
|
|
|||
|
|
@ -108,6 +108,20 @@ describe('CrossTeamService', () => {
|
|||
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 () => {
|
||||
await service.send(makeRequest());
|
||||
|
||||
|
|
|
|||
|
|
@ -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('task_start');
|
||||
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_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
||||
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_CLOSE}`);
|
||||
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('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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue