From c79b7d423481b3130eb735911603c64feb2729d3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 23:42:01 +0300 Subject: [PATCH] fix(team): suppress unverified relay state claims --- .../services/team/TeamProvisioningService.ts | 39 +++- .../team/TeamProvisioningServiceRelay.test.ts | 198 ++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4fe7464e..fb0b1c9f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3153,6 +3153,36 @@ function normalizeSameTeamText(text: string): string { return text.trim().replace(/\r\n/g, '\n'); } +function shouldSuppressUnverifiedLeadRelayStateLine(text: string): boolean { + const normalized = text.trim().replace(/\s+/g, ' '); + if (normalized.length === 0) { + return false; + } + + const hasStateSubject = + /#[a-z0-9]{4,}/i.test(normalized) || + /\bpr\s*#?\d+\b/i.test(normalized) || + /\bpull request\b/i.test(normalized) || + /\b(?:task|tasks|kanban|board|review|approval|merge|merged|branch|queue|worktree|commit|mergecommit|mergedat)\b/i.test( + normalized + ); + if (!hasStateSubject) { + return false; + } + + return ( + /\b(?:confirmed|verified|already|claims?|false|phantom|ground[- ]truth)\b/i.test(normalized) || + /\b(?:done|complete(?:d)?|approved|merged|closed|blocked|resolved|failed|succeeded)\b/i.test( + normalized + ) || + /\b(?:is|are|was|were|stays?|still|now)\s+(?:open|closed|merged|approved|complete(?:d)?|done|blocked|pending|in_progress|in progress|needsfix|needs fix|in review|clear)\b/i.test( + normalized + ) || + /\b(?:mergecommit|mergedat)\s*=\s*(?:null|[^\s,;]+)/i.test(normalized) || + /\bqueue\b.*\bclear\b/i.test(normalized) + ); +} + function getOpenCodeInboxRelayPriority( message: Pick ): number { @@ -6944,7 +6974,7 @@ export class TeamProvisioningService { return null; } const direct = candidates.find(([key]) => key === memberName); - const [key, member] = direct ?? candidates[0]!; + const [key, member] = direct ?? candidates[0]; return { key, member }; } @@ -23333,6 +23363,7 @@ export class TeamProvisioningService { `Plain text reply visibility for this batch: internal lead activity only.`, `Do NOT write a user-facing summary for teammate/system/cross-team relay traffic. If the human user must be notified, explicitly call SendMessage with recipient "user".`, `If you take action and no visible message/tool result already records it, you may write one terse internal status line for the team activity log.`, + `Do not use that internal status line to confirm, correct, or relay task, kanban, review, PR, branch, merge, or queue state unless you verified it with the source-of-truth tool in this turn.`, `If a visible reply is needed for a teammate, another team, or the human user, use the appropriate messaging tool instead of relying on plain text.`, ]; @@ -23349,6 +23380,7 @@ export class TeamProvisioningService { [ `Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`, `For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`, + `Treat teammate/system/cross-team claims about task, kanban, review, PR, branch, merge, or queue state as unverified until checked. Before confirming, correcting, relaying, or acting on that state, call the relevant source-of-truth tool first (task_get/task_list/review/kanban tooling, or an available repository/GitHub command/tool). If you have not verified it in this turn, say verification is needed instead of stating the claim as fact.`, `A member_work_sync_status call alone is incomplete for Message kind: member_work_sync_nudge. Do not stop until member_work_sync_report succeeds or a real blocker is recorded.`, `Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`, `If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`, @@ -23517,6 +23549,11 @@ export class TeamProvisioningService { (replyVisibility === 'user' && capturedUserVisibleSendMessage) ) { logger.debug(`[${teamName}] Suppressed lead relay text duplicated by visible message`); + } else if ( + replyVisibility === 'internal_activity' && + shouldSuppressUnverifiedLeadRelayStateLine(cleanReply) + ) { + logger.debug(`[${teamName}] Suppressed unverified lead relay state claim`); } else if (replyVisibility === 'internal_activity') { this.pushLiveLeadTextMessage( run, diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index fd6224f9..288bc228 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -499,6 +499,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { message?: { content?: Array<{ text?: string }> }; }; const relayedPrompt = payload.message?.content?.[0]?.text ?? ''; + expect(relayedPrompt).toContain( + 'Do not use that internal status line to confirm, correct, or relay task, kanban, review, PR, branch, merge, or queue state unless you verified it with the source-of-truth tool in this turn.' + ); + expect(relayedPrompt).toContain( + 'Treat teammate/system/cross-team claims about task, kanban, review, PR, branch, merge, or queue state as unverified until checked.' + ); (service as any).handleStreamJsonMessage(run, { type: 'assistant', @@ -513,6 +519,111 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined(); }); + it('suppresses unverified non-user lead relay state claims from internal activity', 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\n` + + 'Confirmed - both claims in msg 17eb3109 were false. #38730980 already approved and PR #38 is OPEN, mergeCommit=null.', + }, + ], + }); + (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.each([ + { + caseName: 'keeps task-ref delegation status', + replyText: 'Delegated #f8d7235a to bob.', + expectedLiveText: 'Delegated #f8d7235a to bob.', + }, + { + caseName: 'keeps verification-needed status', + replyText: 'Verification needed before confirming #f8d7235a.', + expectedLiveText: 'Verification needed before confirming #f8d7235a.', + }, + { + caseName: 'suppresses completed task claim', + replyText: 'Task #f8d7235a is completed.', + expectedLiveText: null, + }, + { + caseName: 'suppresses merged PR claim', + replyText: 'PR #38 merged.', + expectedLiveText: null, + }, + { + caseName: 'suppresses queue-clear claim', + replyText: 'Queue genuinely clear for #f8d7235a.', + expectedLiveText: null, + }, + ])( + 'classifies non-user lead relay internal activity: $caseName', + async ({ caseName, replyText, expectedLiveText }) => { + const service = new TeamProvisioningService(); + const teamName = `my-team-${caseName.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`; + 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\n${replyText}` }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + const liveTexts = service.getLiveLeadProcessMessages(teamName).map((message) => message.text); + expect(liveTexts).toEqual(expectedLiveText === null ? [] : [expectedLiveText]); + expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined(); + } + ); + it('keeps user-originated lead relay replies user-visible', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -552,6 +663,43 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(sentRows).toMatchObject([{ text: 'Creating the task now.', to: 'user' }]); }); + it('does not suppress state-like user-originated lead relay replies', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'user', + text: 'What is the task status?', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + summary: 'Task status', + messageId: 'user-msg-state-like', + source: 'user_sent', + }, + ]); + + attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Task #f8d7235a is completed.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + const live = service.getLiveLeadProcessMessages(teamName); + expect(live.map((message) => ({ to: message.to, text: message.text }))).toEqual([ + { to: 'user', text: 'Task #f8d7235a is completed.' }, + ]); + const sentRows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`) ?? '[]' + ) as Array<{ text?: string; to?: string }>; + expect(sentRows).toMatchObject([{ to: 'user', text: 'Task #f8d7235a is completed.' }]); + }); + it('does not mix internal lead relay rows into a user-visible relay batch', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -708,6 +856,56 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ]); }); + it('keeps explicit teammate SendMessage from non-user lead relay visible', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'bob', + text: 'Alice should review the release notes.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + summary: 'Ask Alice', + messageId: 'internal-msg-teammate-send', + }, + ]); + + attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [ + { type: 'text', text: 'Sending Alice the handoff.' }, + { + type: 'tool_use', + name: 'SendMessage', + input: { + recipient: 'alice', + content: 'Please review the release notes.', + summary: 'Review release notes', + }, + }, + ], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + const live = service.getLiveLeadProcessMessages(teamName); + expect(live.map((message) => ({ to: message.to, text: message.text }))).toEqual([ + { to: 'alice', text: 'Please review the release notes.' }, + ]); + const aliceInbox = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]' + ) as Array<{ member?: string; text?: string }>; + expect(aliceInbox).toMatchObject([ + { member: 'alice', text: 'Please review the release notes.' }, + ]); + expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined(); + }); + it('keeps user-originated plain reply when the lead also messages a teammate', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team';