fix(team): support OpenCode lead runtime sessions
This commit is contained in:
parent
9af0a0df2b
commit
961770b7a7
8 changed files with 709 additions and 29 deletions
|
|
@ -9660,6 +9660,28 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
private async hasDeliverableOpenCodeRuntimeSessionForRecipient(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<boolean> {
|
||||
const identity = await this.resolveOpenCodeMemberDeliveryIdentity(teamName, memberName).catch(
|
||||
() => null
|
||||
);
|
||||
if (!identity?.ok) {
|
||||
return false;
|
||||
}
|
||||
const runId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, identity.laneId);
|
||||
if (!runId) {
|
||||
return false;
|
||||
}
|
||||
return this.hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence({
|
||||
teamName,
|
||||
runId,
|
||||
laneId: identity.laneId,
|
||||
memberName: identity.canonicalMemberName,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveOpenCodeMembersForRuntimeLane(
|
||||
teamName: string,
|
||||
laneId: string
|
||||
|
|
@ -14300,7 +14322,27 @@ export class TeamProvisioningService {
|
|||
if (!memberName) continue;
|
||||
|
||||
const isLead = isLeadMember({ name: memberName, agentType: member.agentType });
|
||||
if (isLead) {
|
||||
const leadRuntimeMember = isLead ? getLiveRuntimeMember(memberName) : undefined;
|
||||
const leadLaunchMember = isLead ? launchSnapshot?.members[memberName] : undefined;
|
||||
const leadActiveRunMember = isLead ? activeRunMemberByName.get(memberName) : undefined;
|
||||
const leadRuntimeModel =
|
||||
leadRuntimeMember?.model?.trim() ||
|
||||
leadActiveRunMember?.model?.trim() ||
|
||||
leadLaunchMember?.model?.trim() ||
|
||||
member.model?.trim() ||
|
||||
undefined;
|
||||
const leadProviderId =
|
||||
normalizeOptionalTeamProviderId(leadActiveRunMember?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(leadRuntimeMember?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(leadLaunchMember?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(member.providerId) ??
|
||||
inferTeamProviderIdFromModel(leadRuntimeModel) ??
|
||||
(currentRuntimeAdapterRun?.providerId === 'opencode' &&
|
||||
currentRuntimeAdapterRun.members?.[memberName]
|
||||
? 'opencode'
|
||||
: undefined);
|
||||
const useRuntimeSnapshotForLead = isLead && leadProviderId === 'opencode';
|
||||
if (isLead && !useRuntimeSnapshotForLead) {
|
||||
const pid = run?.child?.pid;
|
||||
const usageStats = pid
|
||||
? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, {
|
||||
|
|
@ -15986,6 +16028,7 @@ export class TeamProvisioningService {
|
|||
extraCliArgs: teamMeta?.extraCliArgs,
|
||||
},
|
||||
members: effectiveMembers,
|
||||
leadName: leadMember?.name?.trim() || 'team-lead',
|
||||
prompt: [
|
||||
`Restarting OpenCode teammate "${targetRuntimeMember.name}" by user request.`,
|
||||
'This is an app-managed OpenCode-only runtime refresh. Re-establish the team sessions and continue from persisted team context.',
|
||||
|
|
@ -18806,6 +18849,31 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
private buildOpenCodePrimaryRuntimeMembers(input: {
|
||||
request: TeamCreateRequest | TeamLaunchRequest;
|
||||
members: TeamCreateRequest['members'];
|
||||
launchCwd: string;
|
||||
leadName?: string;
|
||||
}): TeamCreateRequest['members'] {
|
||||
const leadName = input.leadName?.trim() || 'team-lead';
|
||||
const normalizedLeadName = leadName.toLowerCase();
|
||||
const runtimeMembers = input.members.filter(
|
||||
(member) => member.name.trim().toLowerCase() !== normalizedLeadName
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: leadName,
|
||||
role: 'Team Lead',
|
||||
providerId: 'opencode',
|
||||
model: input.request.model,
|
||||
effort: input.request.effort,
|
||||
cwd: input.launchCwd,
|
||||
},
|
||||
...runtimeMembers,
|
||||
];
|
||||
}
|
||||
|
||||
private async materializeOpenCodeRuntimeAdapterDefaults<
|
||||
TRequest extends TeamCreateRequest | TeamLaunchRequest,
|
||||
>(params: {
|
||||
|
|
@ -20582,6 +20650,7 @@ export class TeamProvisioningService {
|
|||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||
request: launchRequest,
|
||||
members: effectiveMembers,
|
||||
leadName: 'team-lead',
|
||||
prompt: launchRequest.prompt?.trim() ?? '',
|
||||
sourceWarning: undefined,
|
||||
onProgress,
|
||||
|
|
@ -20637,6 +20706,7 @@ export class TeamProvisioningService {
|
|||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||
request: launchRequest,
|
||||
members: effectiveMembers,
|
||||
leadName: this.extractLeadNameFromConfigRaw(configRaw) ?? 'team-lead',
|
||||
prompt,
|
||||
sourceWarning: warning,
|
||||
onProgress,
|
||||
|
|
@ -20646,6 +20716,7 @@ export class TeamProvisioningService {
|
|||
private async runOpenCodeTeamRuntimeAdapterLaunch(input: {
|
||||
request: TeamCreateRequest | TeamLaunchRequest;
|
||||
members: TeamCreateRequest['members'];
|
||||
leadName?: string;
|
||||
prompt: string;
|
||||
sourceWarning?: string;
|
||||
onProgress: (progress: TeamProvisioningProgress) => void;
|
||||
|
|
@ -20707,6 +20778,12 @@ export class TeamProvisioningService {
|
|||
state: 'active',
|
||||
});
|
||||
const launchCwd = this.getOpenCodeRuntimeLaunchCwd(input.request.cwd, input.members);
|
||||
const runtimeMembers = this.buildOpenCodePrimaryRuntimeMembers({
|
||||
request: input.request,
|
||||
members: input.members,
|
||||
launchCwd,
|
||||
leadName: input.leadName,
|
||||
});
|
||||
const launchInput: TeamRuntimeLaunchInput = {
|
||||
runId,
|
||||
laneId: 'primary',
|
||||
|
|
@ -20717,7 +20794,7 @@ export class TeamProvisioningService {
|
|||
model: input.request.model,
|
||||
effort: input.request.effort,
|
||||
skipPermissions: input.request.skipPermissions !== false,
|
||||
expectedMembers: input.members.map((member) => ({
|
||||
expectedMembers: runtimeMembers.map((member) => ({
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
|
|
@ -20912,7 +20989,10 @@ export class TeamProvisioningService {
|
|||
result,
|
||||
});
|
||||
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
for (const member of input.expectedMembers) {
|
||||
const persistedExpectedMembers = input.expectedMembers.filter(
|
||||
(member) => !isLeadMember({ name: member.name })
|
||||
);
|
||||
for (const member of persistedExpectedMembers) {
|
||||
const evidence = committedResult.members[member.name];
|
||||
members[member.name] = this.toOpenCodePersistedLaunchMember(
|
||||
member,
|
||||
|
|
@ -20922,8 +21002,8 @@ export class TeamProvisioningService {
|
|||
}
|
||||
const snapshot = createPersistedLaunchSnapshot({
|
||||
teamName: input.teamName,
|
||||
expectedMembers: input.expectedMembers.map((member) => member.name),
|
||||
bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name),
|
||||
expectedMembers: persistedExpectedMembers.map((member) => member.name),
|
||||
bootstrapExpectedMembers: persistedExpectedMembers.map((member) => member.name),
|
||||
leadSessionId: result.leadSessionId,
|
||||
launchPhase: committedResult.launchPhase,
|
||||
members,
|
||||
|
|
@ -22457,19 +22537,26 @@ export class TeamProvisioningService {
|
|||
);
|
||||
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
|
||||
if (isOpenCodeRecipient) {
|
||||
const diagnostic =
|
||||
'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.';
|
||||
logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`);
|
||||
const hasLeadSession = await this.hasDeliverableOpenCodeRuntimeSessionForRecipient(
|
||||
teamName,
|
||||
inboxName
|
||||
);
|
||||
if (!hasLeadSession) {
|
||||
const diagnostic =
|
||||
'opencode_lead_runtime_session_missing: OpenCode lead runtime session is not available; leaving inbox unread for durable retry/diagnostics.';
|
||||
logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`);
|
||||
return {
|
||||
kind: 'opencode_lead_unsupported',
|
||||
relayed: 0,
|
||||
diagnostics: [diagnostic],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
kind: 'opencode_lead_unsupported',
|
||||
relayed: 0,
|
||||
diagnostics: [diagnostic],
|
||||
kind: 'native_lead',
|
||||
relayed: this.isTeamAlive(teamName) ? await this.relayLeadInboxMessages(teamName) : 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'native_lead',
|
||||
relayed: this.isTeamAlive(teamName) ? await this.relayLeadInboxMessages(teamName) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (isOpenCodeRecipient) {
|
||||
|
|
@ -25313,6 +25400,20 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const activeRuntimeRunId =
|
||||
run?.runId?.trim() || currentRuntimeAdapterRun?.runId?.trim() || runId?.trim() || '';
|
||||
const getCurrentRuntimeAdapterEvidence = (
|
||||
memberName: string
|
||||
): TeamRuntimeMemberLaunchEvidence | undefined => {
|
||||
const direct = currentRuntimeAdapterRun?.members?.[memberName];
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
return Object.entries(currentRuntimeAdapterRun?.members ?? {}).find(
|
||||
([candidateName, evidence]) =>
|
||||
matchesTeamMemberIdentity(candidateName, memberName) ||
|
||||
matchesTeamMemberIdentity(evidence.memberName ?? '', memberName)
|
||||
)?.[1];
|
||||
};
|
||||
const committedPrimarySessionStatusByMember = new Map<string, MemberSpawnStatusEntry>();
|
||||
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
|
||||
const memberName = persistedMember.name?.trim() ?? '';
|
||||
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
||||
|
|
@ -25320,12 +25421,12 @@ export class TeamProvisioningService {
|
|||
}
|
||||
const activeRunMember = this.findEffectiveRunMember(run, memberName);
|
||||
const activeRunModel = activeRunMember?.model?.trim();
|
||||
const evidenceModel = currentRuntimeAdapterRun?.members?.[memberName]?.model?.trim();
|
||||
const currentRuntimeAdapterEvidence = getCurrentRuntimeAdapterEvidence(memberName);
|
||||
const evidenceModel = currentRuntimeAdapterEvidence?.model?.trim();
|
||||
const activeRunProviderId =
|
||||
normalizeOptionalTeamProviderId(activeRunMember?.providerId) ??
|
||||
inferTeamProviderIdFromModel(activeRunModel ?? evidenceModel);
|
||||
const effectiveProviderId = activeRunProviderId ?? persistedMember.providerId;
|
||||
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||
upsertMetadata(memberName, {
|
||||
backendType:
|
||||
effectiveProviderId === 'opencode'
|
||||
|
|
@ -25363,6 +25464,128 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
for (const [rawMemberName, evidence] of Object.entries(
|
||||
currentRuntimeAdapterRun?.members ?? {}
|
||||
)) {
|
||||
const memberName = evidence.memberName?.trim() || rawMemberName.trim();
|
||||
if (!memberName || memberName.toLowerCase() === 'user') {
|
||||
continue;
|
||||
}
|
||||
if (this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
||||
continue;
|
||||
}
|
||||
const evidenceProviderId =
|
||||
normalizeOptionalTeamProviderId(evidence.providerId) ??
|
||||
currentRuntimeAdapterRun?.providerId;
|
||||
const runtimeModel =
|
||||
evidence.model?.trim() ||
|
||||
this.findEffectiveRunMemberModel(run, memberName) ||
|
||||
this.findConfiguredMemberModel(configuredMembers, memberName) ||
|
||||
this.findMetaMemberModel(metaMembers, memberName);
|
||||
upsertMetadata(memberName, {
|
||||
backendType:
|
||||
evidence.backendType ??
|
||||
(evidenceProviderId === 'opencode'
|
||||
? 'process'
|
||||
: metadataByMember.get(memberName)?.backendType),
|
||||
providerId: evidenceProviderId,
|
||||
alive: false,
|
||||
livenessKind: evidence.livenessKind,
|
||||
pidSource: evidence.pidSource,
|
||||
runtimeDiagnostic: evidence.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: evidence.runtimeDiagnosticSeverity,
|
||||
...(runtimeModel ? { model: runtimeModel } : {}),
|
||||
...(typeof evidence.runtimePid === 'number' && evidence.runtimePid > 0
|
||||
? { metricsPid: evidence.runtimePid }
|
||||
: {}),
|
||||
...(evidence.sessionId ? { runtimeSessionId: evidence.sessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const configuredLeadForRuntime = configuredMembers.find((member) => isLeadMember(member));
|
||||
const primaryLeadProviderId =
|
||||
normalizeOptionalTeamProviderId(configuredLeadForRuntime?.providerId) ??
|
||||
inferTeamProviderIdFromModel(configuredLeadForRuntime?.model);
|
||||
const shouldReadPrimaryOpenCodeCommittedSessions =
|
||||
currentRuntimeAdapterRun?.providerId === 'opencode' || primaryLeadProviderId === 'opencode';
|
||||
if (shouldReadPrimaryOpenCodeCommittedSessions) {
|
||||
const primaryRunId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, 'primary').catch(
|
||||
() => null
|
||||
);
|
||||
const committedEvidence = primaryRunId
|
||||
? await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName,
|
||||
laneId: 'primary',
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
const committedActiveRunId = committedEvidence?.activeRunId?.trim() || null;
|
||||
if (committedEvidence?.committed === true && committedActiveRunId === primaryRunId) {
|
||||
for (const session of committedEvidence.sessions) {
|
||||
if (session.runId !== primaryRunId) {
|
||||
continue;
|
||||
}
|
||||
const memberName = session.memberName.trim();
|
||||
if (
|
||||
!memberName ||
|
||||
memberName.toLowerCase() === 'user' ||
|
||||
this.isMemberRemovedInMeta(metaMembers, memberName) ||
|
||||
getCurrentRuntimeAdapterEvidence(memberName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const configuredMember = configuredMembers.find((member) =>
|
||||
matchesTeamMemberIdentity(member.name ?? '', memberName)
|
||||
);
|
||||
const metaMember = metaMembers.find((member) =>
|
||||
matchesTeamMemberIdentity(member.name ?? '', memberName)
|
||||
);
|
||||
const runtimeModel =
|
||||
this.findEffectiveRunMemberModel(run, memberName) ||
|
||||
metaMember?.model?.trim() ||
|
||||
configuredMember?.model?.trim() ||
|
||||
undefined;
|
||||
const memberProviderId =
|
||||
normalizeOptionalTeamProviderId(metaMember?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(configuredMember?.providerId) ??
|
||||
inferTeamProviderIdFromModel(runtimeModel) ??
|
||||
(isLeadMember({
|
||||
name: memberName,
|
||||
agentType: metaMember?.agentType ?? configuredMember?.agentType,
|
||||
}) && primaryLeadProviderId === 'opencode'
|
||||
? 'opencode'
|
||||
: undefined);
|
||||
if (memberProviderId !== 'opencode') {
|
||||
continue;
|
||||
}
|
||||
const observedAt = session.observedAt ?? nowIso();
|
||||
committedPrimarySessionStatusByMember.set(memberName, {
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
updatedAt: observedAt,
|
||||
firstSpawnAcceptedAt: observedAt,
|
||||
lastHeartbeatAt: observedAt,
|
||||
});
|
||||
upsertMetadata(memberName, {
|
||||
backendType: 'process',
|
||||
providerId: 'opencode',
|
||||
alive: true,
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'runtime_bootstrap',
|
||||
runtimeDiagnostic: 'bootstrap confirmed',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
...(runtimeModel ? { model: runtimeModel } : {}),
|
||||
runtimeSessionId: session.id,
|
||||
runtimeLastSeenAt: observedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const paneIds = [...metadataByMember.values()]
|
||||
.filter((metadata) => metadata.backendType === 'tmux' || metadata.backendType === undefined)
|
||||
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
|
||||
|
|
@ -25425,7 +25648,7 @@ export class TeamProvisioningService {
|
|||
for (const [memberName, metadata] of metadataByMember.entries()) {
|
||||
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
||||
const launchMember = persistedLaunchSnapshot?.members[memberName];
|
||||
const adapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||
const adapterEvidence = getCurrentRuntimeAdapterEvidence(memberName);
|
||||
const adapterStatus: MemberSpawnStatusEntry | undefined = adapterEvidence
|
||||
? {
|
||||
status: adapterEvidence.hardFailure
|
||||
|
|
@ -25474,11 +25697,12 @@ export class TeamProvisioningService {
|
|||
launchMember
|
||||
? this.buildLaunchMemberSpawnStatus(launchMember, metadata.model)
|
||||
: undefined;
|
||||
const committedPrimarySessionStatus = committedPrimarySessionStatusByMember.get(memberName);
|
||||
const status = this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, launchStatus)
|
||||
? launchStatus
|
||||
: this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, adapterStatus)
|
||||
? adapterStatus
|
||||
: (trackedStatus ?? adapterStatus ?? launchStatus);
|
||||
: (trackedStatus ?? adapterStatus ?? launchStatus ?? committedPrimarySessionStatus);
|
||||
const resolved = resolveTeamMemberRuntimeLiveness({
|
||||
teamName,
|
||||
memberName,
|
||||
|
|
@ -37298,6 +37522,19 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private extractLeadNameFromConfigRaw(configRaw: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(configRaw) as { members?: TeamConfig['members'] };
|
||||
if (!Array.isArray(parsed.members)) {
|
||||
return null;
|
||||
}
|
||||
const lead = parsed.members.find((member) => isLeadMember(member));
|
||||
return lead?.name?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-stage preflight check:
|
||||
* 1. `claude --version` verifies the binary is executable.
|
||||
|
|
|
|||
|
|
@ -1116,10 +1116,19 @@ function buildMemberBootstrapPrompt(
|
|||
const teamPrompt = input.prompt?.trim();
|
||||
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
|
||||
const workflow = member.workflow?.trim();
|
||||
const isTeamLead =
|
||||
member.name.trim().toLowerCase() === 'team-lead' || role.trim().toLowerCase() === 'team lead';
|
||||
const identityLine = isTeamLead
|
||||
? `You are ${member.name}, the team lead for team "${input.teamName}".`
|
||||
: `You are ${member.name}, a ${role} on team "${input.teamName}".`;
|
||||
const messageTargets = isTeamLead
|
||||
? 'the human user or a teammate'
|
||||
: 'the human user, team lead, or another teammate';
|
||||
const senderRole = isTeamLead ? 'team lead' : 'OpenCode teammate';
|
||||
return [
|
||||
'<agent_teams_app_managed_bootstrap_briefing>',
|
||||
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
|
||||
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
|
||||
identityLine,
|
||||
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
|
||||
workflow ? `Workflow:\n${workflow}` : null,
|
||||
'',
|
||||
|
|
@ -1131,8 +1140,8 @@ function buildMemberBootstrapPrompt(
|
|||
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
|
||||
'If the briefing says there are no actionable tasks, stay idle silently.',
|
||||
'',
|
||||
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
|
||||
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
|
||||
`When you need to message ${messageTargets}, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.`,
|
||||
`Always set from="${member.name}" when sending a team message from this ${senderRole}.`,
|
||||
'Do not answer team/app messages only as plain assistant text when agent-teams_message_send is available.',
|
||||
'</agent_teams_app_managed_bootstrap_briefing>',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -63,7 +63,11 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
|
|||
const launchInput = captureAdapter.launchInputs[0];
|
||||
expect(launchInput).toBeDefined();
|
||||
expect(launchInput?.prompt ?? '').toContain('production desktop app');
|
||||
expect(launchInput?.expectedMembers.map((member) => member.name)).toEqual(['bob', 'jack']);
|
||||
expect(launchInput?.expectedMembers.map((member) => member.name)).toEqual([
|
||||
'team-lead',
|
||||
'bob',
|
||||
'jack',
|
||||
]);
|
||||
expect(launchInput?.prompt?.length ?? 0).toBeGreaterThan(1_500);
|
||||
|
||||
const bridgeCapture = createCapturingOpenCodeBridge(selectedModel);
|
||||
|
|
@ -78,7 +82,11 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
|
|||
expect(launchCommand?.leadPrompt).toContain('OpenCode members bootstrap silently');
|
||||
expect(launchCommand?.leadPrompt.length ?? 0).toBeGreaterThan(1_500);
|
||||
expect(launchCommand?.leadPrompt.length ?? 0).toBeLessThan(80_000);
|
||||
expect(launchCommand?.members.map((member) => member.name)).toEqual(['bob', 'jack']);
|
||||
expect(launchCommand?.members.map((member) => member.name)).toEqual([
|
||||
'team-lead',
|
||||
'bob',
|
||||
'jack',
|
||||
]);
|
||||
|
||||
for (const member of launchCommand?.members ?? []) {
|
||||
expect(member.prompt).toContain(`You are ${member.name}`);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
import { TeamInboxWriter } from '../../../../src/main/services/team/TeamInboxWriter';
|
||||
import { getTeamsBasePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import {
|
||||
createOpenCodeLiveHarness,
|
||||
getRuntimeTranscript,
|
||||
|
|
@ -162,6 +163,140 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
300_000
|
||||
);
|
||||
|
||||
it(
|
||||
'relays a desktop inbox message to the OpenCode lead session and records the lead reply',
|
||||
async () => {
|
||||
const { bridgeClient, selectedModel, svc, dispose } = await createOpenCodeLiveHarness({
|
||||
tempDir,
|
||||
selectedModel: process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL,
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
|
||||
const teamName = `opencode-lead-message-${Date.now()}`;
|
||||
const leadName = 'team-lead';
|
||||
const memberName = 'bob';
|
||||
const expectedReply = `opencode-lead-message-e2e-${Date.now()}`;
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
|
||||
try {
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: PROJECT_PATH,
|
||||
providerId: 'opencode',
|
||||
model: selectedModel,
|
||||
skipPermissions: true,
|
||||
members: [
|
||||
{
|
||||
name: memberName,
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: selectedModel,
|
||||
},
|
||||
],
|
||||
},
|
||||
(progress) => {
|
||||
progressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
|
||||
expect(runId).toBeTruthy();
|
||||
const progressDump = progressEvents
|
||||
.map((progress) =>
|
||||
[
|
||||
progress.state,
|
||||
progress.message,
|
||||
progress.messageSeverity,
|
||||
progress.error,
|
||||
progress.cliLogsTail,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
)
|
||||
.join('\n');
|
||||
expect(
|
||||
progressEvents.some((progress) =>
|
||||
progress.message.includes('OpenCode team launch is ready')
|
||||
),
|
||||
progressDump
|
||||
).toBe(true);
|
||||
|
||||
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(runtimeSnapshot.members[leadName]).toMatchObject({
|
||||
alive: true,
|
||||
runtimeModel: selectedModel,
|
||||
});
|
||||
|
||||
const written = await new TeamInboxWriter().sendMessage(teamName, {
|
||||
member: leadName,
|
||||
from: 'user',
|
||||
to: leadName,
|
||||
source: 'user_sent',
|
||||
text: [
|
||||
`Reply to the app Messages UI with exactly: ${expectedReply}`,
|
||||
`Use agent-teams_message_send with to="user" and from="${leadName}".`,
|
||||
'Do not answer only as plain assistant text.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
let lastRelay: Awaited<ReturnType<typeof svc.relayInboxFileToLiveRecipient>> | null = null;
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
lastRelay = await svc.relayInboxFileToLiveRecipient(teamName, leadName, {
|
||||
onlyMessageId: written.messageId,
|
||||
source: 'ui-send',
|
||||
deliveryMetadata: { replyRecipient: 'user' },
|
||||
});
|
||||
if (lastRelay.relayed >= 1) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
lastRelay.kind === 'opencode_lead_unsupported' ||
|
||||
(lastRelay.lastDelivery?.delivered === false &&
|
||||
lastRelay.lastDelivery.responsePending !== true)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
||||
}
|
||||
|
||||
expect(lastRelay).toMatchObject({
|
||||
kind: 'opencode_member',
|
||||
relayed: 1,
|
||||
});
|
||||
|
||||
let reply: InboxMessage;
|
||||
try {
|
||||
reply = await waitForUserInboxReply(teamName, leadName, expectedReply, 90_000);
|
||||
} catch (error) {
|
||||
const transcript = await getRuntimeTranscript({
|
||||
bridgeClient,
|
||||
teamName,
|
||||
memberName: leadName,
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nLast relay: ${JSON.stringify(
|
||||
lastRelay,
|
||||
null,
|
||||
2
|
||||
)}\nTranscript: ${JSON.stringify(transcript, null, 2)}`
|
||||
);
|
||||
}
|
||||
expect(reply).toMatchObject({
|
||||
from: leadName,
|
||||
to: 'user',
|
||||
});
|
||||
expect(reply.text).toContain(expectedReply);
|
||||
} finally {
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
await dispose();
|
||||
await waitForOpenCodeLanesStopped(teamName);
|
||||
}
|
||||
},
|
||||
300_000
|
||||
);
|
||||
|
||||
it(
|
||||
'relays an OpenCode teammate message into another OpenCode member runtime and records the reply',
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -202,6 +202,75 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('builds a lead-specific OpenCode bootstrap prompt for team-lead sessions', async () => {
|
||||
const launchOpenCodeTeam = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
||||
>(async () => ({
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
members: {
|
||||
'team-lead': {
|
||||
sessionId: 'oc-lead-session',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimePid: 123,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
evidence: [
|
||||
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
alice: {
|
||||
sessionId: 'oc-alice-session',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimePid: 124,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
evidence: [
|
||||
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
}));
|
||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-lead')),
|
||||
launchOpenCodeTeam,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||
|
||||
await adapter.launch(
|
||||
launchInput({
|
||||
expectedMembers: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
role: 'Team Lead',
|
||||
providerId: 'opencode',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
cwd: '/repo',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
cwd: '/repo',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const command = launchOpenCodeTeam.mock.calls[0]?.[0];
|
||||
const leadPrompt = command?.members.find((member) => member.name === 'team-lead')?.prompt;
|
||||
expect(leadPrompt).toContain('You are team-lead, the team lead');
|
||||
expect(leadPrompt).toContain('message the human user or a teammate');
|
||||
expect(leadPrompt).toContain('Always set from="team-lead"');
|
||||
expect(leadPrompt).not.toContain('human user, team lead, or another teammate');
|
||||
});
|
||||
|
||||
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
|
||||
const firstReadiness = readiness({
|
||||
state: 'mcp_unavailable',
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||
expect(adapter.launchInputs).toHaveLength(1);
|
||||
expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([
|
||||
'team-lead',
|
||||
'alice',
|
||||
'bob',
|
||||
]);
|
||||
|
|
@ -164,11 +165,28 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
runtimeModel: 'opencode/big-pickle',
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(getTeamsBasePath(), 'pure-opencode-safe-e2e', 'launch-state.json'), {
|
||||
const launchState = JSON.parse(
|
||||
await fs.readFile(path.join(getTeamsBasePath(), 'pure-opencode-safe-e2e', 'launch-state.json'), {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
).resolves.toContain('"teamLaunchState": "clean_success"');
|
||||
) as { expectedMembers: string[]; members: Record<string, unknown>; teamLaunchState: string };
|
||||
expect(launchState.teamLaunchState).toBe('clean_success');
|
||||
expect(launchState.expectedMembers).toEqual(['alice', 'bob']);
|
||||
expect(Object.keys(launchState.members)).toEqual(['alice', 'bob']);
|
||||
await expect(
|
||||
readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: 'pure-opencode-safe-e2e',
|
||||
laneId: 'primary',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
committed: true,
|
||||
sessions: expect.arrayContaining([
|
||||
expect.objectContaining({ memberName: 'team-lead' }),
|
||||
expect.objectContaining({ memberName: 'alice' }),
|
||||
expect.objectContaining({ memberName: 'bob' }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts pure OpenCode runtime bootstrap check-ins during adapter launch', async () => {
|
||||
|
|
@ -191,7 +209,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||
expect(adapter.bootstrapCheckins).toEqual([
|
||||
{
|
||||
memberName: 'alice',
|
||||
memberName: 'team-lead',
|
||||
runId,
|
||||
state: 'accepted',
|
||||
},
|
||||
|
|
@ -263,6 +281,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||
expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([
|
||||
'team-lead',
|
||||
'alice',
|
||||
'bob',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4848,6 +4848,128 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reports a runtime-backed OpenCode lead as a process member', async () => {
|
||||
const teamName = 'pure-opencode-runtime-lead-team';
|
||||
const projectPath = '/Users/test/project';
|
||||
const runId = 'opencode-runtime-run';
|
||||
writeLaunchConfig(teamName, projectPath, 'lead-session', []);
|
||||
writeLaunchState(teamName, 'lead-session', {
|
||||
'team-lead': {
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/big-pickle',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
runtimePid: 333,
|
||||
runtimeRunId: runId,
|
||||
runtimeSessionId: 'session-team-lead',
|
||||
},
|
||||
});
|
||||
vi.mocked(listRuntimeProcessTableForCurrentPlatform).mockResolvedValue([
|
||||
{
|
||||
pid: 333,
|
||||
ppid: 1,
|
||||
command: 'node /tmp/opencode-bridge.js --team-name pure-opencode-runtime-lead-team',
|
||||
},
|
||||
]);
|
||||
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||
'333': createPidusageStat(333, 456_000_000),
|
||||
} as any);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).runtimeAdapterRunByTeam.set(teamName, {
|
||||
runId,
|
||||
providerId: 'opencode',
|
||||
cwd: projectPath,
|
||||
members: {
|
||||
'team-lead': {
|
||||
memberName: 'team-lead',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/big-pickle',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
runtimePid: 333,
|
||||
sessionId: 'session-team-lead',
|
||||
},
|
||||
},
|
||||
});
|
||||
(svc as any).aliveRunByTeam.set(teamName, runId);
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
|
||||
expect(snapshot.members['team-lead']).toMatchObject({
|
||||
alive: true,
|
||||
backendType: 'process',
|
||||
providerId: 'opencode',
|
||||
runtimeModel: 'opencode/big-pickle',
|
||||
runtimeSessionId: 'session-team-lead',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores OpenCode lead runtime liveness from committed primary session evidence', async () => {
|
||||
const teamName = 'pure-opencode-runtime-lead-restart-team';
|
||||
const projectPath = '/Users/test/project';
|
||||
const runId = 'opencode-runtime-run-after-restart';
|
||||
const teamDir = path.join(tempTeamsBase, teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath,
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
role: 'Team Lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/big-pickle',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName,
|
||||
laneId: 'primary',
|
||||
state: 'active',
|
||||
});
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId: 'primary',
|
||||
runId,
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-team-lead-after-restart',
|
||||
teamName,
|
||||
memberName: 'team-lead',
|
||||
laneId: 'primary',
|
||||
runId,
|
||||
observedAt: '2026-04-22T12:00:00.000Z',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
|
||||
expect(snapshot.members['team-lead']).toMatchObject({
|
||||
alive: true,
|
||||
backendType: 'process',
|
||||
providerId: 'opencode',
|
||||
runtimeModel: 'opencode/big-pickle',
|
||||
runtimeSessionId: 'session-team-lead-after-restart',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles persisted launch state before building runtime snapshot metadata', async () => {
|
||||
const teamName = 'zz-runtime-snapshot-reconciles-before-live-metadata';
|
||||
const leadSessionId = 'lead-session';
|
||||
|
|
@ -14848,6 +14970,24 @@ describe('TeamProvisioningService', () => {
|
|||
})
|
||||
).rejects.toThrow('launch boom');
|
||||
|
||||
expect(adapterLaunch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedMembers: [
|
||||
expect.objectContaining({
|
||||
name: 'team-lead',
|
||||
role: 'Team Lead',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
cwd: '/tmp/opencode-team',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||
lanes: {},
|
||||
});
|
||||
|
|
@ -16798,6 +16938,12 @@ describe('TeamProvisioningService', () => {
|
|||
effort: 'medium',
|
||||
cwd: tempClaudeRoot,
|
||||
expectedMembers: [
|
||||
expect.objectContaining({
|
||||
name: 'team-lead',
|
||||
role: 'Team Lead',
|
||||
providerId: 'opencode',
|
||||
model: 'big-pickle',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
|
|
|
|||
|
|
@ -3905,6 +3905,63 @@ Messages:
|
|||
expect(rows[0].read).toBe(false);
|
||||
});
|
||||
|
||||
it('routes OpenCode lead inbox rows through runtime relay when a lead session exists', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/test',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'user',
|
||||
to: 'team-lead',
|
||||
text: 'Please coordinate.',
|
||||
timestamp: '2026-02-23T17:06:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-lead-runtime-1',
|
||||
},
|
||||
]);
|
||||
vi.spyOn(service as any, 'hasDeliverableOpenCodeRuntimeSessionForRecipient').mockResolvedValue(
|
||||
true
|
||||
);
|
||||
vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({
|
||||
delivered: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead');
|
||||
|
||||
expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 });
|
||||
expect((service as any).hasDeliverableOpenCodeRuntimeSessionForRecipient).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
'team-lead'
|
||||
);
|
||||
expect(service.deliverOpenCodeMemberMessage).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
expect.objectContaining({
|
||||
memberName: 'team-lead',
|
||||
messageId: 'opencode-lead-runtime-1',
|
||||
replyRecipient: 'user',
|
||||
})
|
||||
);
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
|
||||
);
|
||||
expect(rows[0].read).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps failed OpenCode member inbox relay rows unread for retry', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
Loading…
Reference in a new issue