diff --git a/mcp-server/src/tools/messageTools.ts b/mcp-server/src/tools/messageTools.ts index 2c63b023..260931eb 100644 --- a/mcp-server/src/tools/messageTools.ts +++ b/mcp-server/src/tools/messageTools.ts @@ -14,7 +14,7 @@ export function registerMessageTools(server: Pick) { server.addTool({ name: 'message_send', 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({ ...toolContextSchema, to: z.string().min(1), @@ -58,21 +58,26 @@ export function registerMessageTools(server: Pick) { taskRefs, }) => { assertConfiguredTeam(teamName, claudeDir); - return await Promise.resolve( - jsonTextContent( - getController(teamName, claudeDir).messages.sendMessage({ - to, - text, - ...(from ? { from } : {}), - ...(summary ? { summary } : {}), - ...(source ? { source } : {}), - ...(relayOfMessageId ? { relayOfMessageId } : {}), - ...(leadSessionId ? { leadSessionId } : {}), - ...(attachments?.length ? { attachments } : {}), - ...(taskRefs?.length ? { taskRefs } : {}), - }) - ) - ); + const result = getController(teamName, claudeDir).messages.sendMessage({ + to, + text, + ...(from ? { from } : {}), + ...(summary ? { summary } : {}), + ...(source ? { source } : {}), + ...(relayOfMessageId ? { relayOfMessageId } : {}), + ...(leadSessionId ? { leadSessionId } : {}), + ...(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), protocolInstruction } + : { result, protocolInstruction }; + return await Promise.resolve(jsonTextContent(payload)); }, }); } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index df98328f..a5f04b8f 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -1427,6 +1427,7 @@ describe('agent-teams-mcp tools', () => { ); 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 rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(rows[0].source).toBe('system_notification'); diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index d5715e62..96e92971 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -143,7 +143,7 @@ function extractOpenCodeMemberSessionRecordedAt( diagnostics: readonly string[] | undefined ): string[] { 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]] : []; }); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9e92316f..285da83e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2260,7 +2260,7 @@ function extractOpenCodeMemberSessionRecordedAt( diagnostics: readonly string[] | undefined ): string[] { 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]] : []; }); } diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 0a725514..b686585f 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -85,7 +85,7 @@ const REQUIRED_READY_CHECKPOINTS = new Set([ const GENERIC_OPEN_CODE_MEMBER_FAILURE_REASON = 'OpenCode bridge reported member launch failure'; const SECRET_FLAG_PATTERN = /(--(?: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; export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { @@ -797,6 +797,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) input.messageId ? `Include relayOfMessageId="${input.messageId}" in that message_send call.` : 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 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.', diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index fedcd09b..d36d1379 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -4620,47 +4620,51 @@ describe('TeamDataService', () => { }); 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 .spyOn(gitIdentityResolver, 'getBranch') - .mockImplementation(async (cwd) => (cwd === '/repo-alice' ? 'feature/alice' : 'main')); + .mockImplementation(async (cwd) => (cwd === aliceRepoPath ? 'feature/alice' : 'main')); const harness = createGetTeamDataHarness({ config: buildDefaultTeamConfig({ - projectPath: '/repo', + projectPath: rootRepoPath, members: [ - { name: 'team-lead', role: 'Lead', cwd: '/repo' }, - { name: 'alice', role: 'Developer', cwd: '/repo-alice' }, + { name: 'team-lead', role: 'Lead', cwd: rootRepoPath }, + { name: 'alice', role: 'Developer', cwd: aliceRepoPath }, ], }), resolveMembers: () => [ - { ...buildResolvedMember('team-lead'), cwd: '/repo' }, - { ...buildResolvedMember('alice'), cwd: '/repo-alice' }, + { ...buildResolvedMember('team-lead'), cwd: rootRepoPath }, + { ...buildResolvedMember('alice'), cwd: aliceRepoPath }, ], }); const data = await harness.service.getTeamData('my-team'); - expect(getBranchSpy).toHaveBeenCalledWith('/repo'); - expect(getBranchSpy).toHaveBeenCalledWith('/repo-alice'); + expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath); + expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath); expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe( 'feature/alice' ); }); 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 .spyOn(gitIdentityResolver, 'getBranch') - .mockImplementation(async (cwd) => (cwd === '/repo-alice' ? 'feature/alice' : 'main')); + .mockImplementation(async (cwd) => (cwd === aliceRepoPath ? 'feature/alice' : 'main')); const harness = createGetTeamDataHarness({ config: buildDefaultTeamConfig({ - projectPath: '/repo', + projectPath: rootRepoPath, members: [ - { name: 'team-lead', role: 'Lead', cwd: '/repo' }, - { name: 'alice', role: 'Developer', cwd: '/repo-alice' }, + { name: 'team-lead', role: 'Lead', cwd: rootRepoPath }, + { name: 'alice', role: 'Developer', cwd: aliceRepoPath }, ], }), resolveMembers: () => [ - { ...buildResolvedMember('team-lead'), cwd: '/repo' }, - { ...buildResolvedMember('alice'), cwd: '/repo-alice' }, + { ...buildResolvedMember('team-lead'), cwd: rootRepoPath }, + { ...buildResolvedMember('alice'), cwd: aliceRepoPath }, ], }); @@ -4668,8 +4672,8 @@ describe('TeamDataService', () => { includeMemberBranches: true, }); - expect(getBranchSpy).toHaveBeenCalledWith('/repo'); - expect(getBranchSpy).toHaveBeenCalledWith('/repo-alice'); + expect(getBranchSpy).toHaveBeenCalledWith(rootRepoPath); + expect(getBranchSpy).toHaveBeenCalledWith(aliceRepoPath); expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe( 'feature/alice' );