diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 93826dd6..471be252 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -9660,6 +9660,28 @@ export class TeamProvisioningService { }; } + private async hasDeliverableOpenCodeRuntimeSessionForRecipient( + teamName: string, + memberName: string + ): Promise { + 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 = {}; - 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(); 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. diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index e986e5c3..f4f46d00 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -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_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.', '', ] diff --git a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts index 6759ba7c..766e4e39 100644 --- a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts +++ b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts @@ -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}`); diff --git a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts index a1e09ef5..52f04514 100644 --- a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts +++ b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts @@ -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> | 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 () => { diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 41d5f240..5eaef3d1 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -202,6 +202,75 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); }); + it('builds a lead-specific OpenCode bootstrap prompt for team-lead sessions', async () => { + const launchOpenCodeTeam = vi.fn< + NonNullable + >(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', diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 904aeddd..3dab3d1b 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -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; 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', ]); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 3837ad3a..ea8c7ac6 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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', diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 3588f7e1..6d57a430 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -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';