fix(ci): stabilize opencode delivery checks

This commit is contained in:
777genius 2026-05-05 01:16:55 +03:00
parent c8edfc6026
commit 7ab57bc8be
6 changed files with 46 additions and 35 deletions

View file

@ -14,7 +14,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'message_send', name: 'message_send',
description: description:
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. from is required and must be your configured teammate name; user is reserved for app-owned writes. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.', 'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. from is required and must be your configured teammate name; user is reserved for app-owned writes. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. After a successful app-delivered runtime reply, stop and do not send the same answer again. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
to: z.string().min(1), to: z.string().min(1),
@ -58,21 +58,26 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
taskRefs, taskRefs,
}) => { }) => {
assertConfiguredTeam(teamName, claudeDir); assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve( const result = getController(teamName, claudeDir).messages.sendMessage({
jsonTextContent( to,
getController(teamName, claudeDir).messages.sendMessage({ text,
to, ...(from ? { from } : {}),
text, ...(summary ? { summary } : {}),
...(from ? { from } : {}), ...(source ? { source } : {}),
...(summary ? { summary } : {}), ...(relayOfMessageId ? { relayOfMessageId } : {}),
...(source ? { source } : {}), ...(leadSessionId ? { leadSessionId } : {}),
...(relayOfMessageId ? { relayOfMessageId } : {}), ...(attachments?.length ? { attachments } : {}),
...(leadSessionId ? { leadSessionId } : {}), ...(taskRefs?.length ? { taskRefs } : {}),
...(attachments?.length ? { attachments } : {}), });
...(taskRefs?.length ? { taskRefs } : {}), const protocolInstruction =
}) source === 'runtime_delivery' || relayOfMessageId
) ? 'Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.'
); : 'Delivered. If this answered one app/user instruction, do not call message_send again for the same answer.';
const payload =
result && typeof result === 'object' && !Array.isArray(result)
? { ...(result as Record<string, unknown>), protocolInstruction }
: { result, protocolInstruction };
return await Promise.resolve(jsonTextContent(payload));
}, },
}); });
} }

View file

@ -1427,6 +1427,7 @@ describe('agent-teams-mcp tools', () => {
); );
expect(sent.deliveredToInbox).toBe(true); expect(sent.deliveredToInbox).toBe(true);
expect(sent.protocolInstruction).toContain('do not call message_send again');
const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'); const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows[0].source).toBe('system_notification'); expect(rows[0].source).toBe('system_notification');

View file

@ -143,7 +143,7 @@ function extractOpenCodeMemberSessionRecordedAt(
diagnostics: readonly string[] | undefined diagnostics: readonly string[] | undefined
): string[] { ): string[] {
return (diagnostics ?? []).flatMap((diagnostic) => { return (diagnostics ?? []).flatMap((diagnostic) => {
const match = diagnostic.match(OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN); const match = OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN.exec(diagnostic);
return match?.[1] ? [match[1]] : []; return match?.[1] ? [match[1]] : [];
}); });
} }

View file

@ -2260,7 +2260,7 @@ function extractOpenCodeMemberSessionRecordedAt(
diagnostics: readonly string[] | undefined diagnostics: readonly string[] | undefined
): string[] { ): string[] {
return (diagnostics ?? []).flatMap((diagnostic) => { return (diagnostics ?? []).flatMap((diagnostic) => {
const match = diagnostic.match(OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN); const match = OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN.exec(diagnostic);
return match?.[1] ? [match[1]] : []; return match?.[1] ? [match[1]] : [];
}); });
} }

View file

@ -85,7 +85,7 @@ const REQUIRED_READY_CHECKPOINTS = new Set([
const GENERIC_OPEN_CODE_MEMBER_FAILURE_REASON = 'OpenCode bridge reported member launch failure'; const GENERIC_OPEN_CODE_MEMBER_FAILURE_REASON = 'OpenCode bridge reported member launch failure';
const SECRET_FLAG_PATTERN = const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi; const BEARER_TOKEN_PATTERN = /\bBearer\s+\S+/gi;
const SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g; const SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g;
export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
@ -797,6 +797,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
input.messageId input.messageId
? `Include relayOfMessageId="${input.messageId}" in that message_send call.` ? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
: null, : null,
'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.',
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.', 'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
'Do not answer only with plain assistant text when agent-teams_message_send is available.', 'Do not answer only with plain assistant text when agent-teams_message_send is available.',
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.', 'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',

View file

@ -4620,47 +4620,51 @@ describe('TeamDataService', () => {
}); });
it('keeps member branch enrichment on by default for full UI team data snapshots', async () => { it('keeps member branch enrichment on by default for full UI team data snapshots', async () => {
const rootRepoPath = path.normalize('/repo');
const aliceRepoPath = path.normalize('/repo-alice');
const getBranchSpy = vi const getBranchSpy = vi
.spyOn(gitIdentityResolver, 'getBranch') .spyOn(gitIdentityResolver, 'getBranch')
.mockImplementation(async (cwd) => (cwd === '/repo-alice' ? 'feature/alice' : 'main')); .mockImplementation(async (cwd) => (cwd === aliceRepoPath ? 'feature/alice' : 'main'));
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
config: buildDefaultTeamConfig({ config: buildDefaultTeamConfig({
projectPath: '/repo', projectPath: rootRepoPath,
members: [ members: [
{ name: 'team-lead', role: 'Lead', cwd: '/repo' }, { name: 'team-lead', role: 'Lead', cwd: rootRepoPath },
{ name: 'alice', role: 'Developer', cwd: '/repo-alice' }, { name: 'alice', role: 'Developer', cwd: aliceRepoPath },
], ],
}), }),
resolveMembers: () => [ resolveMembers: () => [
{ ...buildResolvedMember('team-lead'), cwd: '/repo' }, { ...buildResolvedMember('team-lead'), cwd: rootRepoPath },
{ ...buildResolvedMember('alice'), cwd: '/repo-alice' }, { ...buildResolvedMember('alice'), cwd: aliceRepoPath },
], ],
}); });
const data = await harness.service.getTeamData('my-team'); const data = await harness.service.getTeamData('my-team');
expect(getBranchSpy).toHaveBeenCalledWith('/repo'); expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath);
expect(getBranchSpy).toHaveBeenCalledWith('/repo-alice'); expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath);
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe( expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe(
'feature/alice' 'feature/alice'
); );
}); });
it('keeps member branch enrichment on for explicit full UI team data snapshots', async () => { it('keeps member branch enrichment on for explicit full UI team data snapshots', async () => {
const rootRepoPath = path.normalize('/repo');
const aliceRepoPath = path.normalize('/repo-alice');
const getBranchSpy = vi const getBranchSpy = vi
.spyOn(gitIdentityResolver, 'getBranch') .spyOn(gitIdentityResolver, 'getBranch')
.mockImplementation(async (cwd) => (cwd === '/repo-alice' ? 'feature/alice' : 'main')); .mockImplementation(async (cwd) => (cwd === aliceRepoPath ? 'feature/alice' : 'main'));
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
config: buildDefaultTeamConfig({ config: buildDefaultTeamConfig({
projectPath: '/repo', projectPath: rootRepoPath,
members: [ members: [
{ name: 'team-lead', role: 'Lead', cwd: '/repo' }, { name: 'team-lead', role: 'Lead', cwd: rootRepoPath },
{ name: 'alice', role: 'Developer', cwd: '/repo-alice' }, { name: 'alice', role: 'Developer', cwd: aliceRepoPath },
], ],
}), }),
resolveMembers: () => [ resolveMembers: () => [
{ ...buildResolvedMember('team-lead'), cwd: '/repo' }, { ...buildResolvedMember('team-lead'), cwd: rootRepoPath },
{ ...buildResolvedMember('alice'), cwd: '/repo-alice' }, { ...buildResolvedMember('alice'), cwd: aliceRepoPath },
], ],
}); });
@ -4668,8 +4672,8 @@ describe('TeamDataService', () => {
includeMemberBranches: true, includeMemberBranches: true,
}); });
expect(getBranchSpy).toHaveBeenCalledWith('/repo'); expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath);
expect(getBranchSpy).toHaveBeenCalledWith('/repo-alice'); expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath);
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe( expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe(
'feature/alice' 'feature/alice'
); );