fix(team): suppress unverified relay state claims
This commit is contained in:
parent
b15de780cb
commit
c79b7d4234
2 changed files with 236 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue