diff --git a/src/main/index.ts b/src/main/index.ts index 6ba75d59..a780e7b9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -90,8 +90,8 @@ 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 { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -473,7 +473,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise 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; + if (isTeamInternalControlMessageEnvelope(msg)) 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 28ede7ae..668f529e 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -1,7 +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 { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import { createHash } from 'crypto'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; @@ -140,7 +140,7 @@ function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessa } function isVisibleTeamMessage(message: InboxMessage): boolean { - return !isTeamInternalControlMessageText(message.text); + return !isTeamInternalControlMessageEnvelope(message); } function annotateSlashCommandResponses(messages: InboxMessage[]): void { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 76b93249..e7d00a41 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -84,11 +84,14 @@ import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { + isTeamInternalControlMessageText, + stripExactInternalControlEchoPrefix, +} from '@shared/utils/teamInternalControlMessages'; 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 { @@ -143,6 +146,14 @@ import { type TeamRuntimeSettingsJson, } from '../runtime/teamRuntimeSettingsBundle'; +import { + parseBootstrapRuntimeProofDetail, + validateBootstrapRuntimeProofEnvelope, +} from './bootstrap/BootstrapProofValidation'; +import { + buildNativeAppManagedBootstrapSpecs, + type NativeAppManagedBootstrapSpec, +} from './bootstrap/NativeAppManagedBootstrapContextBuilder'; import { createOpenCodePromptDeliveryLedgerStore, hashOpenCodePromptDeliveryPayload, @@ -256,14 +267,6 @@ import { import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; -import { - buildNativeAppManagedBootstrapSpecs, - type NativeAppManagedBootstrapSpec, -} from './bootstrap/NativeAppManagedBootstrapContextBuilder'; -import { - parseBootstrapRuntimeProofDetail, - validateBootstrapRuntimeProofEnvelope, -} from './bootstrap/BootstrapProofValidation'; import type { OpenCodeCommittedBootstrapSessionRecord, @@ -19399,7 +19402,12 @@ export class TeamProvisioningService { // Strip agent-only blocks — lead may respond with pure coordination content // that is not meant for the human user. - const cleanReply = replyText ? stripAgentBlocks(replyText) : null; + const cleanReply = replyText + ? stripExactInternalControlEchoPrefix( + stripAgentBlocks(replyText), + stripAgentBlocks(message) + ) + : null; if (cleanReply) { if (isTeamInternalControlMessageText(cleanReply)) { logger.debug(`[${teamName}] Suppressed internal lead relay echo`); diff --git a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts index ab3d43b5..eb761ac3 100644 --- a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts +++ b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts @@ -1,9 +1,9 @@ +import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import * as agentTeamsControllerModule from 'agent-teams-controller'; import { createHash } from 'crypto'; -import { getClaudeBasePath } from '@main/utils/pathDecoder'; import type { TeamCreateRequest, TeamProviderId } from '@shared/types'; -import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; const { createController } = agentTeamsControllerModule; @@ -41,7 +41,7 @@ function redactNativeBootstrapContextText(input: string): string { .replace(/sk-ant-[A-Za-z0-9_-]+/g, '[REDACTED_ANTHROPIC_API_KEY]') .replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]') .replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_API_KEY)=\S+/g, '$1=[REDACTED]') - .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [REDACTED]'); + .replace(/Bearer\s+[A-Z0-9._-]+/gi, 'Bearer [REDACTED]'); } function boundText(input: string, maxChars: number): string { diff --git a/src/renderer/utils/bootstrapPromptSanitizer.ts b/src/renderer/utils/bootstrapPromptSanitizer.ts index 9c1cc23a..64538dc6 100644 --- a/src/renderer/utils/bootstrapPromptSanitizer.ts +++ b/src/renderer/utils/bootstrapPromptSanitizer.ts @@ -6,7 +6,7 @@ import { } from '@renderer/utils/teamModelCatalog'; import { isNativeAppManagedBootstrapCheckText, - isTeamInternalControlMessageText, + isTeamInternalControlMessageEnvelope, } from '@shared/utils/teamInternalControlMessages'; import type { InboxMessage, TeamProviderId } from '@shared/types'; @@ -135,17 +135,17 @@ export interface InternalControlMessageDisplay { } export function getInternalControlMessageDisplay( - message: Pick + message: Pick & Partial> ): InternalControlMessageDisplay | null { + if (!isTeamInternalControlMessageEnvelope(message)) { + return 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.', @@ -236,7 +236,9 @@ export function getBootstrapAcknowledgementDisplay( }; } -export function getSanitizedInboxMessageText(message: Pick): string { +export function getSanitizedInboxMessageText( + message: Pick & Partial> +): string { return ( getInternalControlMessageDisplay(message)?.body ?? getBootstrapPromptDisplay(message)?.body ?? @@ -247,7 +249,8 @@ export function getSanitizedInboxMessageText(message: Pick + message: Pick & + Partial> ): string { return ( getInternalControlMessageDisplay(message)?.summary ?? diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index f462e8f9..91568dc5 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -4,7 +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 { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import type { InboxMessage } from '@shared/types'; @@ -127,8 +127,7 @@ export function filterTeamMessages( const leadNames = normalizeLeadNames(rawLeadNames); let list = messages.filter( - (m) => - m.messageKind !== 'task_comment_notification' && !isTeamInternalControlMessageText(m.text) + (m) => m.messageKind !== 'task_comment_notification' && !isTeamInternalControlMessageEnvelope(m) ); if (timeWindow) { list = list.filter((m) => { diff --git a/src/shared/utils/teamInternalControlMessages.ts b/src/shared/utils/teamInternalControlMessages.ts index 93e802dd..2c2899aa 100644 --- a/src/shared/utils/teamInternalControlMessages.ts +++ b/src/shared/utils/teamInternalControlMessages.ts @@ -2,7 +2,14 @@ const NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN = ' { expect(feed.messages.map((message) => message.messageId)).toEqual(['visible-user-message']); }); + it('does not hide user-authored text just because it resembles an internal prompt', async () => { + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => config), + getInboxMessages: vi.fn(async () => [ + makeMessage({ + messageId: 'quoted-control-prompt', + source: 'user_sent', + text: `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + }), + ]), + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const feed = await service.getFeed('signal-ops-4'); + + expect(feed.messages.map((message) => message.messageId)).toEqual(['quoted-control-prompt']); + }); + 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 a420ce1e..3919bd64 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -342,6 +342,47 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined(); }); + it('preserves visible summary text after stripping an echoed lead relay prompt', 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 ?? ''; + + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: `Human: ${relayedPrompt}\n\nDelegated to bob.` }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + expect(service.getLiveLeadProcessMessages(teamName).map((message) => message.text)).toEqual([ + 'Delegated to bob.', + ]); + const sentRows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`) ?? '[]' + ) as Array<{ + text?: string; + }>; + expect(sentRows.map((message) => message.text)).toEqual(['Delegated to bob.']); + }); + it('treats member work sync nudges as actionable in lead relay prompt', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/renderer/utils/bootstrapPromptSanitizer.test.ts b/test/renderer/utils/bootstrapPromptSanitizer.test.ts index 773b9208..5220eb80 100644 --- a/test/renderer/utils/bootstrapPromptSanitizer.test.ts +++ b/test/renderer/utils/bootstrapPromptSanitizer.test.ts @@ -67,16 +67,20 @@ Do NOT send acknowledgement-only messages such as "ready" or "online".`); }); it('sanitizes native app-managed bootstrap private control prompts defensively', () => { - const message = makeMessage(` + const message = makeMessage( + ` Your Agent Teams startup context was already loaded by the app. -`); +`, + { source: 'system_notification' } + ); 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"). + 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. @@ -84,9 +88,26 @@ Messages: 1) From: tom Timestamp: 2026-05-06T15:02:54.853Z Text: - #f8d7235a done.`); + #f8d7235a done.`, + { source: 'lead_process' } + ); expect(getInternalControlMessageDisplay(message)?.summary).toBe('Internal control message'); expect(getSanitizedInboxMessageText(message)).toBe('Internal control message hidden in the UI.'); }); + + it('does not sanitize user-authored text that quotes an internal prompt', () => { + const text = `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`; + const message = makeMessage(text, { source: 'user_sent' }); + + expect(getInternalControlMessageDisplay(message)).toBeNull(); + expect(getSanitizedInboxMessageText(message)).toBe(text); + }); }); diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index b9ec6965..503132b8 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -90,6 +90,31 @@ Messages: expect(result.map((message) => message.messageId)).toEqual(['visible-message']); }); + it('does not hide user-authored text that quotes an internal prompt', () => { + const messages = [ + makeMessage({ + messageId: 'quoted-control-prompt', + source: 'user_sent', + text: `Human: You have new inbox messages addressed to you (team lead "team-lead"). +Process them in order (oldest first). + +Messages: +1) From: tom + Timestamp: 2026-05-06T15:02:54.853Z + Text: + #f8d7235a done.`, + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['quoted-control-prompt']); + }); + it('hides Human-prefixed teammate protocol echoes', () => { const messages = [ makeMessage({ diff --git a/test/shared/utils/teamInternalControlMessages.test.ts b/test/shared/utils/teamInternalControlMessages.test.ts index 29d32737..334ed5f7 100644 --- a/test/shared/utils/teamInternalControlMessages.test.ts +++ b/test/shared/utils/teamInternalControlMessages.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + isTeamInternalControlMessageEnvelope, isLeadInboxRelayControlPromptText, isTeamInternalControlMessageText, isTeammateProtocolControlText, + stripExactInternalControlEchoPrefix, } from '@shared/utils/teamInternalControlMessages'; const leadRelayPrompt = `You have new inbox messages addressed to you (team lead "team-lead"). @@ -39,4 +41,36 @@ describe('teamInternalControlMessages', () => { expect(isTeammateProtocolControlText(text)).toBe(true); expect(isTeamInternalControlMessageText(text)).toBe(true); }); + + it('only treats internal-looking text as hidden for internal message sources', () => { + expect( + isTeamInternalControlMessageEnvelope({ + source: 'lead_process', + text: `Human: ${leadRelayPrompt}`, + }) + ).toBe(true); + expect( + isTeamInternalControlMessageEnvelope({ + source: 'user_sent', + text: `Human: ${leadRelayPrompt}`, + }) + ).toBe(false); + expect( + isTeamInternalControlMessageEnvelope({ + text: `Human: ${leadRelayPrompt}`, + }) + ).toBe(false); + }); + + it('strips an exact echoed control prefix while preserving visible trailing text', () => { + expect(stripExactInternalControlEchoPrefix(`Human: ${leadRelayPrompt}`, leadRelayPrompt)).toBe( + '' + ); + expect( + stripExactInternalControlEchoPrefix( + `Human: ${leadRelayPrompt}\n\nDelegated to bob.`, + leadRelayPrompt + ) + ).toBe('Delegated to bob.'); + }); });