fix(team): suppress unverified relay state claims

This commit is contained in:
777genius 2026-05-26 23:42:01 +03:00
parent b15de780cb
commit c79b7d4234
2 changed files with 236 additions and 1 deletions

View file

@ -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<InboxMessage, 'messageKind' | 'source'>
): 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,

View file

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