diff --git a/src/main/ipc/crossTeam.ts b/src/main/ipc/crossTeam.ts index bf520fda..03d57a25 100644 --- a/src/main/ipc/crossTeam.ts +++ b/src/main/ipc/crossTeam.ts @@ -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; + 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, }); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 26328edc..b2156cb5 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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, diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 79de9748..49764a29 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -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 { - 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, }); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index bb77bdfe..ac918478 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 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}" } diff --git a/src/main/services/team/actionModeInstructions.ts b/src/main/services/team/actionModeInstructions.ts new file mode 100644 index 00000000..867c3bfd --- /dev/null +++ b/src/main/services/team/actionModeInstructions.ts @@ -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 = { + 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'; +} diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index afcc0b00..80618c5c 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -646,6 +646,7 @@ const ProjectsGrid = ({ return (
+ {!searchQuery.trim() && } {filteredRepos.map((repo) => { const counts = repo.worktrees.reduce( (acc, wt) => { @@ -673,7 +674,6 @@ const ProjectsGrid = ({ /> ); })} - {!searchQuery.trim() && }
); }; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 770d1a93..ffc9bfd6 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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, }); }} diff --git a/src/renderer/components/team/messages/ActionModeSelector.tsx b/src/renderer/components/team/messages/ActionModeSelector.tsx index d94344fe..440f05ce 100644 --- a/src/renderer/components/team/messages/ActionModeSelector.tsx +++ b/src/renderer/components/team/messages/ActionModeSelector.tsx @@ -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', }, diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 923bc278..0e563787 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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 /> ) : ( Select... @@ -582,6 +592,7 @@ export const MessageComposer = ({ color={resolvedColor} size="sm" hideAvatar={m.name === 'user'} + disableHoverCard /> {role ? ( @@ -612,6 +623,7 @@ export const MessageComposer = ({ color={selectedResolvedColor} size="sm" hideAvatar={recipient === 'user'} + disableHoverCard /> ) : ( Select... @@ -686,6 +698,7 @@ export const MessageComposer = ({ color={resolvedColor} size="sm" hideAvatar={m.name === 'user'} + disableHoverCard /> {role ? ( @@ -732,7 +745,7 @@ export const MessageComposer = ({ } cornerAction={ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e9ca17a5..f4726123 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; } diff --git a/test/main/ipc/crossTeam.test.ts b/test/main/ipc/crossTeam.test.ts index a8b6de01..d777e096 100644 --- a/test/main/ipc/crossTeam.test.ts +++ b/test/main/ipc/crossTeam.test.ts @@ -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')); diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 2025baaf..f79cd66d 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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; diff --git a/test/main/services/team/CrossTeamService.test.ts b/test/main/services/team/CrossTeamService.test.ts index f936618a..8e95b966 100644 --- a/test/main/services/team/CrossTeamService.test.ts +++ b/test/main/services/team/CrossTeamService.test.ts @@ -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()); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 9a7fd6e7..cbd9318b 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -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');