diff --git a/src/main/index.ts b/src/main/index.ts index 37d5534d..6ba75d59 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -90,6 +90,7 @@ import { } from '@shared/constants'; import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics'; import { parseInboxJson } from '@shared/utils/inboxNoise'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { createLogger } from '@shared/utils/logger'; import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; @@ -470,6 +471,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise const msg = newMessages[i]; // Skip messages sent from our own UI if (msg.source && suppressedSources.has(msg.source)) continue; + // Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs, + // not user-visible conversation messages. + if (isTeamInternalControlMessageText(msg.text)) continue; // Skip internal coordination noise (idle_notification, shutdown_*, etc.) if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue; diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index 3865ef3d..28ede7ae 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -1,6 +1,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; import { createLogger } from '@shared/utils/logger'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { createHash } from 'crypto'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; @@ -138,6 +139,10 @@ function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessa })); } +function isVisibleTeamMessage(message: InboxMessage): boolean { + return !isTeamInternalControlMessageText(message.text); +} + function annotateSlashCommandResponses(messages: InboxMessage[]): void { let pendingSlash = null as InboxMessage['slashCommand'] | null; @@ -499,7 +504,9 @@ export class TeamMessageFeedService { const normalizeStartedAt = Date.now(); const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config); - let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages]; + let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter( + isVisibleTeamMessage + ); messages = dedupeLeadProcessCopies(messages, leadTexts); messages = ensureEffectiveMessageIds(messages); messages = dedupeByMessageId(messages); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 58233b05..76b93249 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -88,6 +88,7 @@ import { parseAllTeammateMessages, type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { @@ -19400,24 +19401,28 @@ export class TeamProvisioningService { // that is not meant for the human user. const cleanReply = replyText ? stripAgentBlocks(replyText) : null; if (cleanReply) { - const relayMsg: InboxMessage = { - from: leadName, - to: 'user', - text: cleanReply, - timestamp: nowIso(), - read: true, - summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply, - messageId: `lead-process-${runId}-${Date.now()}`, - source: 'lead_process', - }; - this.pushLiveLeadProcessMessage(teamName, relayMsg); - // Persist to disk so relayed replies survive app restart and trigger FileWatcher - this.persistSentMessage(teamName, relayMsg); - this.teamChangeEmitter?.({ - type: 'inbox', - teamName, - detail: 'lead-process-reply', - }); + if (isTeamInternalControlMessageText(cleanReply)) { + logger.debug(`[${teamName}] Suppressed internal lead relay echo`); + } else { + const relayMsg: InboxMessage = { + from: leadName, + to: 'user', + text: cleanReply, + timestamp: nowIso(), + read: true, + summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply, + messageId: `lead-process-${runId}-${Date.now()}`, + source: 'lead_process', + }; + this.pushLiveLeadProcessMessage(teamName, relayMsg); + // Persist to disk so relayed replies survive app restart and trigger FileWatcher + this.persistSentMessage(teamName, relayMsg); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName, + detail: 'lead-process-reply', + }); + } } return batch.length; @@ -25426,7 +25431,7 @@ export class TeamProvisioningService { !hasCapturedVisibleSendMessage ) { const cleanText = stripAgentBlocks(text).trim(); - if (cleanText.length > 0) { + if (cleanText.length > 0 && !isTeamInternalControlMessageText(cleanText)) { this.pushLiveLeadTextMessage( run, cleanText, @@ -25440,7 +25445,7 @@ export class TeamProvisioningService { // into the live cache so Messages/Activity can show the earliest assistant output. if (!run.silentUserDmForward && !hasCapturedVisibleSendMessage) { const cleanText = stripAgentBlocks(text).trim(); - if (cleanText.length > 0) { + if (cleanText.length > 0 && !isTeamInternalControlMessageText(cleanText)) { this.pushLiveLeadTextMessage( run, cleanText, diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index bcb1626f..46c79f76 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -37,6 +37,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; @@ -73,6 +74,7 @@ export function isLeadThought(msg: InboxMessage): boolean { if (msg.messageKind === 'slash_command_result') return false; // Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden if (isThoughtProtocolNoise(msg.text)) return false; + if (isTeamInternalControlMessageText(msg.text)) return false; if (msg.source === 'lead_session') return true; if (msg.source === 'lead_process') return true; return false; @@ -90,7 +92,7 @@ export function isLeadThought(msg: InboxMessage): boolean { function isLeadSessionNoise(msg: InboxMessage): boolean { if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false; if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; - return isThoughtProtocolNoise(msg.text); + return isThoughtProtocolNoise(msg.text) || isTeamInternalControlMessageText(msg.text); } export type TimelineItem = diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 6b7383f9..141124c9 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -81,6 +81,41 @@ const baseTask: TeamTaskWithKanban = { const noop = (): void => undefined; +async function renderTaskCard( + props: Partial> = {} +): Promise<{ host: HTMLDivElement; root: ReturnType }> { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: baseTask, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + ...props, + }) + ); + await Promise.resolve(); + }); + + return { host, root }; +} + describe('KanbanTaskCard change badge', () => { afterEach(() => { document.body.innerHTML = ''; @@ -197,3 +232,45 @@ describe('KanbanTaskCard change badge', () => { }); }); }); + +describe('KanbanTaskCard blocked border', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('highlights blocked tasks outside final columns', async () => { + const { host, root } = await renderTaskCard({ + task: { ...baseTask, blockedBy: ['task-2'] }, + columnId: 'in_progress', + }); + + const card = host.querySelector('[data-task-id="task-1"]'); + expect(card?.className).toContain('kanban-task-card'); + expect(card?.className).toContain('border-yellow-500/30'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it.each(['done', 'approved'] as const)( + 'does not highlight blocked tasks in %s', + async (columnId) => { + const { host, root } = await renderTaskCard({ + task: { ...baseTask, blockedBy: ['task-2'] }, + columnId, + }); + + const card = host.querySelector('[data-task-id="task-1"]'); + expect(card?.className).not.toContain('border-yellow-500/30'); + expect(card?.className).toContain('border-[var(--color-border)]'); + expect(host.textContent).toContain('Blocked by'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + } + ); +}); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 7c84488d..5d8686f0 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -245,6 +245,7 @@ export const KanbanTaskCard = memo( const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const hasBlockedBy = blockedByIds.length > 0; const hasBlocks = blocksIds.length > 0; + const shouldHighlightBlocked = hasBlockedBy && columnId !== 'done' && columnId !== 'approved'; const cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]'; const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); @@ -288,8 +289,8 @@ export const KanbanTaskCard = memo( return (
+): InternalControlMessageDisplay | null { + if (isNativeAppManagedBootstrapCheckText(message.text)) { + return { + summary: 'Internal bootstrap check', + body: 'Internal bootstrap check hidden in the UI.', + }; + } + if (!isTeamInternalControlMessageText(message.text)) { + return null; + } + return { + summary: 'Internal control message', + body: 'Internal control message hidden in the UI.', + }; +} + export function getBootstrapPromptDisplay( message: Pick ): BootstrapPromptDisplay | null { @@ -211,6 +238,7 @@ export function getBootstrapAcknowledgementDisplay( export function getSanitizedInboxMessageText(message: Pick): string { return ( + getInternalControlMessageDisplay(message)?.body ?? getBootstrapPromptDisplay(message)?.body ?? getBootstrapAcknowledgementDisplay(message as Pick)?.body ?? message.text ?? @@ -222,6 +250,7 @@ export function getSanitizedInboxMessageSummary( message: Pick ): string { return ( + getInternalControlMessageDisplay(message)?.summary ?? getBootstrapPromptDisplay(message)?.summary ?? getBootstrapAcknowledgementDisplay(message)?.summary ?? message.summary ?? diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 47e551a4..f462e8f9 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -4,6 +4,7 @@ import { } from '@renderer/utils/bootstrapPromptSanitizer'; import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; +import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import type { InboxMessage } from '@shared/types'; @@ -125,7 +126,10 @@ export function filterTeamMessages( } = options; const leadNames = normalizeLeadNames(rawLeadNames); - let list = messages.filter((m) => m.messageKind !== 'task_comment_notification'); + let list = messages.filter( + (m) => + m.messageKind !== 'task_comment_notification' && !isTeamInternalControlMessageText(m.text) + ); if (timeWindow) { list = list.filter((m) => { const ts = new Date(m.timestamp).getTime(); diff --git a/src/shared/utils/teamInternalControlMessages.ts b/src/shared/utils/teamInternalControlMessages.ts new file mode 100644 index 00000000..93e802dd --- /dev/null +++ b/src/shared/utils/teamInternalControlMessages.ts @@ -0,0 +1,47 @@ +const NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN = ''; +const LEAD_INBOX_RELAY_PROMPT_OPEN = 'You have new inbox messages addressed to you (team lead '; +const TEAMMATE_MESSAGE_OPEN_RE = /^ { for (const member of launchCommand?.members ?? []) { expect(member.prompt).toContain(`You are ${member.name}`); expect(member.prompt).toContain('Team launch context:'); - expect(member.prompt).toContain('agent-teams_member_briefing'); - expect(member.prompt).toContain('"runtimeProvider": "opencode"'); + expect(member.prompt).toContain('agent_teams_app_managed_bootstrap_briefing'); + expect(member.prompt).toContain('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1'); expect(member.prompt).toContain('agent-teams_message_send'); expect(member.prompt).toContain('Launch bootstrap is a silent attach'); expect(member.prompt).toContain('stay idle silently'); diff --git a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts index 5ab27e5a..aa7adb78 100644 --- a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts +++ b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION, createOpenCodeBridgeHandshakeIdentityHash, type OpenCodeBridgeCommandName, type OpenCodeBridgeHandshake, @@ -272,6 +273,8 @@ function peerIdentity( 'opencode.launchTeam', 'opencode.stopTeam', ], + opencodeAppManagedBootstrapContractVersion: + OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION, }, runtime: { providerId: 'opencode', diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index da5fa3be..55189d09 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -5052,7 +5052,6 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.launchInputs.length === 2); await waitForCondition(() => run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -5094,7 +5093,6 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.launchInputs.length === 2); await waitForCondition(() => run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -5146,13 +5144,17 @@ describe('Team agent launch matrix safe e2e', () => { const svc = new TeamProvisioningService(); svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); + removeMixedOpenCodeLaneForTest(run, 'bob'); trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); await waitForCondition(() => run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); + expect(adapter.launchInputs.map((input) => input.expectedMembers.map((member) => member.name))).toEqual([ + ['tom'], + ]); const statuses = await svc.getMemberSpawnStatuses(teamName); @@ -5215,15 +5217,19 @@ describe('Team agent launch matrix safe e2e', () => { model: 'opencode/nemotron-3-super-free', }, ], - ]); + ]); const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); + removeMixedOpenCodeLaneForTest(run, 'bob'); trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); await waitForCondition(() => run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); + expect(adapter.launchInputs.map((input) => input.expectedMembers.map((member) => member.name))).toEqual([ + ['tom'], + ]); const statuses = await svc.getMemberSpawnStatuses(teamName); @@ -17443,6 +17449,15 @@ function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): vo }; } +function removeMixedOpenCodeLaneForTest(run: any, memberName: string): void { + run.allEffectiveMembers = (run.allEffectiveMembers ?? []).filter( + (member: { name?: string }) => member.name !== memberName + ); + run.mixedSecondaryLanes = (run.mixedSecondaryLanes ?? []).filter( + (lane: { member?: { name?: string } }) => lane.member?.name !== memberName + ); +} + function addGeminiPrimaryToMixedRun(run: any): void { const now = '2026-04-23T10:00:00.000Z'; const reviewer = { diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index b35ddae4..8483ff18 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -74,6 +74,29 @@ describe('TeamMessageFeedService', () => { expect(second.messages).toHaveLength(1); }); + it('hides native app-managed bootstrap private control messages from the feed', async () => { + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages: vi.fn(async () => [ + makeMessage({ + messageId: 'native-bootstrap-private-check', + source: 'system_notification', + text: '\nprivate\n', + }), + makeMessage({ + messageId: 'visible-user-message', + text: 'Visible message', + }), + ]), + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const feed = await service.getFeed('signal-ops-4'); + + expect(feed.messages.map((message) => message.messageId)).toEqual(['visible-user-message']); + }); + it('refreshes the durable feed after cache expiry even when the dirty signal was missed', async () => { let inboxMessages: InboxMessage[] = [makeMessage()]; const getInboxMessages = vi.fn(async () => inboxMessages); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index a3686e61..a420ce1e 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -306,6 +306,42 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); }); + it('does not persist echoed lead relay prompts as user-visible replies', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'tom', + text: '#f8d7235a done.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + summary: '#f8d7235a done', + messageId: 'm-1', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + const payload = JSON.parse(String(writeSpy.mock.calls[0]?.[0] ?? '{}')) as { + message?: { content?: Array<{ text?: string }> }; + }; + const relayedPrompt = payload.message?.content?.[0]?.text ?? ''; + + expect(relayedPrompt).toContain('You have new inbox messages addressed to you'); + + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: `Human: ${relayedPrompt}` }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(0); + expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined(); + }); + it('treats member work sync nudges as actionable in lead relay prompt', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -436,6 +472,37 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { } }); + it('does not show internal control echoes as late lead thoughts', () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + attachAliveRun(service, teamName); + + const run = (service as unknown as { runs: Map }).runs.get('run-1') as { + leadRelayCapture: null; + }; + + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [ + { + type: 'text', + text: `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + }, + ], + }); + + expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(0); + }); + it('adds substantive-only task comment guidance for lead relay prompts', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index 0960a38b..e4d909c6 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -130,6 +130,25 @@ describe('LeadThoughtsGroup', () => { expect(groupTimelineItems([noise])).toEqual([]); }); + it('excludes Human-prefixed internal control echoes from timeline', () => { + const leadRelayEcho = makeLeadSessionMsg(`Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`); + const teammateEcho = makeLeadSessionMsg( + 'Human: {"type":"idle_notification"}' + ); + + expect(isLeadThought(leadRelayEcho)).toBe(false); + expect(isLeadThought(teammateEcho)).toBe(false); + expect(groupTimelineItems([leadRelayEcho, teammateEcho])).toEqual([]); + }); + it('does not exclude noise messages with a recipient (captured SendMessage)', () => { const sendMsg = makeLeadSessionMsg( '{"type":"idle_notification","from":"tom","idleReason":"available"}', diff --git a/test/renderer/utils/bootstrapPromptSanitizer.test.ts b/test/renderer/utils/bootstrapPromptSanitizer.test.ts index e5780ee8..773b9208 100644 --- a/test/renderer/utils/bootstrapPromptSanitizer.test.ts +++ b/test/renderer/utils/bootstrapPromptSanitizer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getInternalControlMessageDisplay, getBootstrapPromptDisplay, getSanitizedInboxMessageText, } from '@renderer/utils/bootstrapPromptSanitizer'; @@ -64,4 +65,28 @@ Do NOT send acknowledgement-only messages such as "ready" or "online".`); expect(display?.runtime).toBe('GPT-5.4 Mini'); }); + + it('sanitizes native app-managed bootstrap private control prompts defensively', () => { + const message = makeMessage(` +Your Agent Teams startup context was already loaded by the app. +`); + + expect(getInternalControlMessageDisplay(message)?.summary).toBe('Internal bootstrap check'); + expect(getSanitizedInboxMessageText(message)).toBe('Internal bootstrap check hidden in the UI.'); + }); + + it('sanitizes leaked lead inbox relay prompts defensively', () => { + const message = makeMessage(`Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`); + + expect(getInternalControlMessageDisplay(message)?.summary).toBe('Internal control message'); + expect(getSanitizedInboxMessageText(message)).toBe('Internal control message hidden in the UI.'); + }); }); diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index f5b02d94..b9ec6965 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -37,6 +37,81 @@ describe('filterTeamMessages', () => { expect(result[0].source).toBe('lead_process'); }); + it('hides native app-managed bootstrap private control messages', () => { + const messages = [ + makeMessage({ + messageId: 'native-bootstrap-private-check', + source: 'system_notification', + text: '\nprivate\n', + }), + makeMessage({ + messageId: 'visible-message', + text: 'Visible message', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['visible-message']); + }); + + it('hides leaked lead inbox relay prompt echoes', () => { + const messages = [ + makeMessage({ + messageId: 'lead-relay-echo', + source: 'lead_process', + to: 'user', + text: `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + }), + makeMessage({ + messageId: 'visible-message', + text: 'Visible message', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['visible-message']); + }); + + it('hides Human-prefixed teammate protocol echoes', () => { + const messages = [ + makeMessage({ + messageId: 'teammate-protocol-echo', + source: 'lead_process', + text: 'Human: {"type":"idle_notification"}', + }), + makeMessage({ + messageId: 'visible-message', + text: 'Visible message', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['visible-message']); + }); + it('hides relay bridge copies when the original message is visible', () => { const messages = [ makeMessage({ diff --git a/test/shared/utils/teamInternalControlMessages.test.ts b/test/shared/utils/teamInternalControlMessages.test.ts new file mode 100644 index 00000000..29d32737 --- /dev/null +++ b/test/shared/utils/teamInternalControlMessages.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + isLeadInboxRelayControlPromptText, + isTeamInternalControlMessageText, + isTeammateProtocolControlText, +} from '@shared/utils/teamInternalControlMessages'; + +const leadRelayPrompt = `You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). +If action is required, delegate via task creation or SendMessage, and keep responses minimal. +IMPORTANT: Your text response here is shown to the user. + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`; + +describe('teamInternalControlMessages', () => { + it('detects lead inbox relay prompts and Human-prefixed echoes', () => { + expect(isLeadInboxRelayControlPromptText(leadRelayPrompt)).toBe(true); + expect(isLeadInboxRelayControlPromptText(`Human: ${leadRelayPrompt}`)).toBe(true); + expect(isTeamInternalControlMessageText(`Human: ${leadRelayPrompt}`)).toBe(true); + }); + + it('does not hide ordinary visible lead replies', () => { + expect( + isLeadInboxRelayControlPromptText( + 'I delegated #f8d7235a to tom and asked alice to review when blockers clear.' + ) + ).toBe(false); + }); + + it('detects Human-prefixed teammate protocol blocks', () => { + const text = + 'Human: \n{"type":"idle_notification"}\n'; + + expect(isTeammateProtocolControlText(text)).toBe(true); + expect(isTeamInternalControlMessageText(text)).toBe(true); + }); +});