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';
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,
});

View file

@ -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,

View file

@ -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,
});

View file

@ -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}" }

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 (
<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>
);
};

View file

@ -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,
});
}}

View file

@ -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',
},

View file

@ -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={

View file

@ -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;
}

View file

@ -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'));

View file

@ -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;

View file

@ -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());

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('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');