fix(ci): stabilize opencode delivery checks
This commit is contained in:
parent
c8edfc6026
commit
7ab57bc8be
6 changed files with 46 additions and 35 deletions
|
|
@ -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));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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]] : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]] : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue